From 2e010d5bc3cf28bf598caef223cb40f9f2224ab4 Mon Sep 17 00:00:00 2001 From: sunheyi <1061867552@qq.com> Date: Mon, 31 Aug 2026 17:36:54 +0800 Subject: [PATCH] feat(computer-use): add Windows .NET feasibility spike Add a supervised UIA/WGC helper, deterministic Windows lifecycle fixtures, reproduction scripts, and published-artifact results for the bounded spike. Keep product integration disabled and record outstanding feasibility evidence. Cover C# and project files in the existing ASF source-header audit. Refs #4318 Refs #3785 Generated-by: OpenAI Codex --- .github/ASF_SOURCE_HEADERS.md | 4 +- experiments/maka-cu-windows/.gitignore | 5 + experiments/maka-cu-windows/README.md | 128 +++ experiments/maka-cu-windows/RESULTS.md | 99 ++ experiments/maka-cu-windows/capture-probe.mjs | 92 ++ experiments/maka-cu-windows/driver.mjs | 220 ++++ .../HangWindowFixture.csproj | 32 + .../fixture/HangWindowFixture/Program.cs | 234 +++++ .../maka-cu-windows/lifecycle-driver.mjs | 695 ++++++++++++ experiments/maka-cu-windows/parent-probe.mjs | 70 ++ .../maka-cu-windows/protocol-regression.mjs | 135 +++ experiments/maka-cu-windows/publish.ps1 | 69 ++ .../maka-cu-windows/src/MakaCuWindows.csproj | 42 + experiments/maka-cu-windows/src/Program.cs | 988 ++++++++++++++++++ experiments/maka-cu-windows/src/WgcCapture.cs | 421 ++++++++ scripts/asf-license-headers.mjs | 2 + scripts/asf-license-headers.test.mjs | 2 + 17 files changed, 3236 insertions(+), 2 deletions(-) create mode 100644 experiments/maka-cu-windows/.gitignore create mode 100644 experiments/maka-cu-windows/README.md create mode 100644 experiments/maka-cu-windows/RESULTS.md create mode 100644 experiments/maka-cu-windows/capture-probe.mjs create mode 100644 experiments/maka-cu-windows/driver.mjs create mode 100644 experiments/maka-cu-windows/fixture/HangWindowFixture/HangWindowFixture.csproj create mode 100644 experiments/maka-cu-windows/fixture/HangWindowFixture/Program.cs create mode 100644 experiments/maka-cu-windows/lifecycle-driver.mjs create mode 100644 experiments/maka-cu-windows/parent-probe.mjs create mode 100644 experiments/maka-cu-windows/protocol-regression.mjs create mode 100644 experiments/maka-cu-windows/publish.ps1 create mode 100644 experiments/maka-cu-windows/src/MakaCuWindows.csproj create mode 100644 experiments/maka-cu-windows/src/Program.cs create mode 100644 experiments/maka-cu-windows/src/WgcCapture.cs diff --git a/.github/ASF_SOURCE_HEADERS.md b/.github/ASF_SOURCE_HEADERS.md index d84a84eac7..49f9e16d58 100644 --- a/.github/ASF_SOURCE_HEADERS.md +++ b/.github/ASF_SOURCE_HEADERS.md @@ -71,10 +71,10 @@ Subtracting in a checkout is not the enumeration excusing itself. It decides whi | Comment syntax | Extensions | | --- | --- | -| `/* … */` | `.cjs`, `.css`, `.js`, `.mjs`, `.mts`, `.rs`, `.swift`, `.ts`, `.tsx` | +| `/* … */` | `.cjs`, `.cs`, `.css`, `.js`, `.mjs`, `.mts`, `.rs`, `.swift`, `.ts`, `.tsx` | | `//` | `.jsonc` | | `#` | `.ps1`, `.py`, `.sh`, `.toml`, `.yaml`, `.yml`, `Dockerfile`, `network-policy` | -| `` | `.html`, `.md` | +| `` | `.csproj`, `.html`, `.md` | ## Reviewed exclusions diff --git a/experiments/maka-cu-windows/.gitignore b/experiments/maka-cu-windows/.gitignore new file mode 100644 index 0000000000..27c5ac7a88 --- /dev/null +++ b/experiments/maka-cu-windows/.gitignore @@ -0,0 +1,5 @@ +**/bin/ +**/obj/ +out/ +*.log +captures/ diff --git a/experiments/maka-cu-windows/README.md b/experiments/maka-cu-windows/README.md new file mode 100644 index 0000000000..ea985359e3 --- /dev/null +++ b/experiments/maka-cu-windows/README.md @@ -0,0 +1,128 @@ + + +# maka-cu-windows feasibility spike + +This private experiment supports apache/maka#4318. It is a supervised, +fixture-only feasibility spike for a long-lived C# helper on Windows. It does +not enable a Windows product backend, change the public protocol, or perform +keyboard, pointer, coordinate, `PostMessage`, `SendInput`, foreground, or +screen-rectangle fallback actions. + +The local machine has only .NET SDK 8.0.421, so the spike targets +`net8.0-windows10.0.22621.0`. .NET 8 is temporary evidence only; a production +follow-up must evaluate .NET 10 LTS and rebuild self-contained artifacts for +runtime patching. No SDK was installed globally for this spike. + +## Components + +- `src/Program.cs`: line-delimited JSON-RPC 2.0, an MTA UIA lane, bounded + request/snapshot registries, strict process-start/window-generation identity, + opaque snapshot tokens, typed readback outcomes, and cancellation settlement. +- `src/WgcCapture.cs`: target-window `CreateForWindow(HWND)` capture and a + D3D11 staging-texture PNG encoder. Capture has no rectangle fallback and + reports `capture_unavailable` on failure. +- `fixture/HangWindowFixture`: purpose-built WinForms fixture. `freeze` blocks + its UI thread so UIA provider calls can hang; `recreate` replaces its HWND; + `cover` tests target capture under occlusion. +- `driver.mjs`: safe smoke flow. It accepts only the exact fixture HWND and + never scans or mutates user windows. +- `lifecycle-driver.mjs`: C4–C6, identity, and parent-death reproduction + scenarios. It owns and tears down all fixture/helper processes. +- `parent-probe.mjs`: short lived host used to prove helper parent-death + cleanup after an initialized, blocked observe. +- `protocol-regression.mjs`: malformed-method, unknown-cancel, and EOF plus + stdout-backpressure regressions without a GUI. +- `publish.ps1`: reproducible self-contained single-file publish plus manifest + and SHA-256 hash. + +## Build and fixture run + +```powershell +dotnet build experiments/maka-cu-windows/src/MakaCuWindows.csproj -c Release +dotnet build experiments/maka-cu-windows/fixture/HangWindowFixture/HangWindowFixture.csproj -c Release +node experiments/maka-cu-windows/lifecycle-driver.mjs ` + experiments/maka-cu-windows/src/bin/Release/net8.0-windows10.0.22621.0/maka-cu-windows.exe ` + experiments/maka-cu-windows/fixture/HangWindowFixture/bin/Release/net8.0-windows10.0.22621.0/maka-cu-windows-fixture.exe +node experiments/maka-cu-windows/protocol-regression.mjs ` + experiments/maka-cu-windows/out/publish/maka-cu-windows.exe +``` + +The lifecycle run starts only the named fixture and helper. The fixture window +is visible because UIA and WGC require an interactive desktop. Do not point +either driver at a user application. + +## Published artifact + +```powershell +powershell -ExecutionPolicy Bypass -File experiments/maka-cu-windows/publish.ps1 +node experiments/maka-cu-windows/lifecycle-driver.mjs ` + experiments/maka-cu-windows/out/publish/maka-cu-windows.exe ` + experiments/maka-cu-windows/out/fixture/maka-cu-windows-fixture.exe +``` + +The intended layout is self-contained `win-x64`, single-file, trimming +disabled, unsigned, and `distributionReady: false`. The manifest records the +actual SDK, target framework, publish settings, sizes, and hashes. The +published run is the relevant packaging evidence; `dotnet run` is not. + +## Current local evidence (Windows 11 Insider 10.0.26220, x64) + +| Check | Result | Evidence or limit | +| --- | --- | --- | +| 1. handshake | pass | Protocol `maka.cu.windows/0`; 10 s handshake / 20 s request / 2 s cancel grace declared. | +| 2. MTA UIA observation | pass on fixture | Dedicated MTA lane, bounded shallow tree, exact PID/HWND/start time/generation. | +| 3. semantic action | pass on fixture | `ValuePattern.SetValue`, pre-dispatch snapshot spend, strict revalidation, readback; duplicate tokens refuse. | +| 4. WGC target capture | pass on fixture | `CreateForWindow(HWND)` plus D3D11 staging readback produced real PNG bytes (464x352, 9,366 bytes); decoded LimeGreen sentinel remained present under an occluding fixture cover and after uncover. No rectangle fallback. | +| 5. cancellation settlement | pass on fixture | In-flight post-dispatch cancellation settles the original request with `verified`; queued cancellation settles `refused/cancelled_before_dispatch` and readback proves no mutation. | +| 6. hung-provider recovery | pass on fixture/helper | Frozen fixture remains alive; supervisor kills helper after 2 s, confirms exit, restarts with a new generation, and old snapshots are unknown. | +| identity cases | partial | Whole-window recreation returns a new HWND and rejects the old snapshot; same-window control replacement was not exercised. New explicit selection is required. | +| parent death | pass on fixture/helper | Dedicated parent probe completed initialize and a blocked observe, exited host code 77, and the helper disappeared without the lifecycle driver killing that helper PID. EOF and fail-closed stdout backpressure also exited within the deadline. A production integration should still add independent OS-level supervision. | + +These are development-machine results, not clean-machine or supported-release +certification. A clean machine with no .NET runtime/SDK and an interactive +desktop has not been run here, so the spike is not a production go decision. + +## D1–D6 decision record + +- **D1 UIA binding:** retain managed `System.Windows.Automation` for the spike; + the dedicated MTA lane and targeted `FindFirst` avoid unbounded Chromium + subtree enumeration. A blocked provider still needs helper restart. +- **D2 frame transport:** use bounded base64 PNG in the private RPC envelope + for the spike. The complete UTF-8 response is capped at 6 MiB; capture + dimensions/pixels and PNG bytes are bounded before/after allocations. +- **D3 publishing/runtime:** evaluate the checked-in .NET 8 single-file + settings with `publish.ps1`; move the baseline to .NET 10 LTS before + production. Native extraction and clean-machine behavior remain unverified. +- **D4 supervision:** keep cancellation control out of the UIA lane; settle + known outcomes, spend queued mutation snapshots, and force-restart a helper + after the 2 s grace. The spike's bounded writer fails closed under blocked + stdout; a Windows Job Object or independent parent watchdog remains a + production hardening item. +- **D5 code home:** keep the prototype under `experiments/maka-cu-windows/` + until all six checks and clean-machine evidence pass. +- **D6 Go boundary:** no Go forwarding helper is justified by current local + evidence; revisit only with measured packaging or integration constraints. + +Recommendation: **hold production enablement; the feasibility gate is not +complete**. The local fixture and published artifact checks pass, but this is +an Insider development machine. Same-window control replacement, clean-machine +evidence, and packaging measurements remain open under #4318. A supported-release +run and stronger OS-level parent ownership should be addressed before rollout. +Signing is deferred production distribution work, not an additional spike gate. diff --git a/experiments/maka-cu-windows/RESULTS.md b/experiments/maka-cu-windows/RESULTS.md new file mode 100644 index 0000000000..7566ec78ab --- /dev/null +++ b/experiments/maka-cu-windows/RESULTS.md @@ -0,0 +1,99 @@ + + +# Local spike results + +Run date: 2026-08-31 (Asia/Shanghai). Environment: Windows 11 Pro Insider +Preview x64 build 10.0.26220, interactive session 3; Node.js 24.19; .NET +SDK 8.0.421; target `net8.0-windows10.0.22621.0`; Windows SDK reference +10.0.22621.56. This is development-machine evidence, not clean-machine or +supported-release certification. + +## Build and package + +Both projects built with `dotnet build ... -c Release --no-restore`: 0 +warnings, 0 errors. `publish.ps1` then produced self-contained `win-x64`, +single-file, untrimmed artifacts (`distributionReady: false`). The manifest +records helper 188,261,839 bytes, SHA-256 +`25A38D720641428BA1AB6E512E5E8A9773D9D79435D1BE99FDF59A095546D1A1`, and +fixture 188,220,927 bytes, SHA-256 +`AEEF7ADC6C16205A0137B63DDA524E29ADAE135CEBD660AA870C11E2CC7BE71D`. + +The publish used the installed .NET 8.0.421 SDK only. A clean-machine restore +and runtime test were not performed. + +## Published lifecycle evidence + +Command: + +```powershell +node experiments/maka-cu-windows/lifecycle-driver.mjs ` + experiments/maka-cu-windows/out/publish/maka-cu-windows.exe ` + experiments/maka-cu-windows/out/fixture/maka-cu-windows-fixture.exe +``` + +Result: `failures=0`. The fixture reported outer `SIZE 480x360` and DWM +`CAPTURE_SIZE 464x352`; WGC returned a real decoded PNG at 464x352 (9,366 +bytes). The stable LimeGreen sentinel decoded to 4,000 pixels in baseline, +covered, and uncovered frames. The run passed C4 capture and occlusion, C5a +in-flight settlement (`verified/value_set`), C5b queued cancellation with no +mutation, C5c control-plane responsiveness, C6 blocked request unsettled +through the 2-second grace and fixture survival, helper termination/restart, +and fresh readback. + +The same run passed exact PID+HWND whole-window recreation checks: the old +snapshot refused with `stale_target_revalidate_failed`, the new HWND was +selected explicitly, and a fresh observe succeeded. Same-window control +replacement was not exercised. The parent probe printed +`HOST_STAGE initialized` and `HOST_STAGE observe_sent`, exited host code 77, +and the helper disappeared without the lifecycle driver killing that helper. +EOF stdin closure exited a new helper with code 0 in 10 ms. All owned fixture +and helper processes were absent after teardown. + +## Protocol regressions + +Command: + +```powershell +node experiments/maka-cu-windows/protocol-regression.mjs ` + experiments/maka-cu-windows/out/publish/maka-cu-windows.exe +``` + +Result: `protocol failures=0`. A wrong method type returned JSON-RPC `-32600` +and exited cleanly; unknown cancellation id 999 emitted no notification +response and left both unrelated `debug_sleep` requests complete; 1,000 +initialize messages with stdout intentionally undrained exited fail-closed +with code 2 rather than hanging indefinitely. + +## Limits and decision + +The experiment remains fixture-only and does not enable a Windows production +backend or use global input, screen-rectangle fallback, arbitrary app launch, +or user-window selection. Process start time and window generation are +required; unavailable identity data refuses actions. GUID tokens are unique per +snapshot, but same-value HWND reuse cannot be proven as impossible by this +fingerprint and remains a documented residual risk. A production integration +should add OS-level parent ownership (for example a Job Object), signing, +supported-release testing, and clean-machine evidence. + +Recommendation: **hold production enablement; the feasibility gate is not +complete** despite the local fixture and packaged spike passing. Same-window +control replacement, clean-machine execution, and packaging measurements still +need evidence under #4318. This is not a demonstrated technical no-go for .NET. +Signing remains deferred production distribution work, not an extra spike gate. diff --git a/experiments/maka-cu-windows/capture-probe.mjs b/experiments/maka-cu-windows/capture-probe.mjs new file mode 100644 index 0000000000..6eac5721ba --- /dev/null +++ b/experiments/maka-cu-windows/capture-probe.mjs @@ -0,0 +1,92 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +// Focused WGC probe. Starts only the purpose-built fixture and helper, asks +// for one target-window capture, and always tears both down. +import { spawn } from 'node:child_process'; +import { createInterface } from 'node:readline'; + +const helper = process.argv[2]; +const fixture = process.argv[3]; +if (!helper || !fixture) throw new Error('usage: node capture-probe.mjs '); + +const fx = spawn(fixture, [], { stdio: ['pipe', 'pipe', 'inherit'] }); +const fr = createInterface({ input: fx.stdout }); +let target; +const ready = new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error('fixture READY timeout')), 8000); + fr.on('line', (line) => { + const m = line.match(/^READY (\d+) (\d+)$/); + if (m) { + clearTimeout(timer); + target = { pid: Number(m[1]), hwnd: Number(m[2]) }; + resolve(); + } + }); +}); + +const h = spawn(helper, [], { stdio: ['pipe', 'pipe', 'pipe'] }); +h.stderr.on('data', (chunk) => process.stderr.write(chunk)); +const hr = createInterface({ input: h.stdout }); +let nextId = 1; +const pending = new Map(); +hr.on('line', (line) => { + const msg = JSON.parse(line); + const p = pending.get(msg.id); + if (p) { + pending.delete(msg.id); + p(msg); + } +}); +const call = (method, params = {}, timeout = 10000) => + new Promise((resolve, reject) => { + const id = nextId++; + pending.set(id, resolve); + h.stdin.write(JSON.stringify({ jsonrpc: '2.0', id, method, params }) + '\n'); + setTimeout(() => { + if (pending.delete(id)) reject(new Error(`${method} timeout`)); + }, timeout); + }); + +try { + await ready; + const hello = await call('initialize'); + const capture = await call( + 'capture', + { + hwnd: target.hwnd, + windowGeneration: (await call('observe', { hwnd: target.hwnd })).result.target + .windowGeneration, + }, + 10000, + ); + console.log(JSON.stringify({ target, hello: hello.result, capture }, null, 2)); +} finally { + try { + h.stdin.end(); + } catch {} + try { + fx.stdin.write('shutdown\n'); + fx.stdin.end(); + } catch {} + setTimeout(() => { + if (!h.killed) h.kill(); + if (!fx.killed) fx.kill(); + }, 300); +} diff --git a/experiments/maka-cu-windows/driver.mjs b/experiments/maka-cu-windows/driver.mjs new file mode 100644 index 0000000000..b88e9cd8ba --- /dev/null +++ b/experiments/maka-cu-windows/driver.mjs @@ -0,0 +1,220 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +// Feasibility spike driver for the maka-cu-windows helper (experiment only). +// Spawns the built exe and walks: initialize -> list_windows -> observe -> +// act (set_value on the first ValuePattern element found, else click) -> +// $/cancel -> shutdown. Prints a short report and sets exit code. +// +// Usage: +// node experiments/maka-cu-windows/driver.mjs +// +// This driver is fixture-only. It never scans for or mutates arbitrary user +// windows: the supplied HWND must belong to the purpose-built +// maka-cu-windows-fixture title discovered by list_windows. +// +// A candidate whose action comes back unknown/refused (e.g. a WebView2 input +// that advertises ValuePattern but ignores SetValue) is recorded as a NOTE +// and the driver moves on to the next candidate — the helper itself stays +// fail-closed (typed outcomes + snapshot spend), which this driver exercises +// on every attempt. + +import { spawn } from 'node:child_process'; +import { createInterface } from 'node:readline/promises'; + +const exe = process.argv[2]; +const hwndOverride = process.argv[3]; +if (!exe || !hwndOverride) { + console.error('usage: node driver.mjs '); + process.exit(2); +} + +const child = spawn(exe, [], { stdio: ['pipe', 'pipe', 'inherit'] }); +const rl = createInterface({ input: child.stdout }); +let nextId = 1; +const pending = new Map(); + +rl.on('line', (line) => { + const msg = JSON.parse(line); + if (msg.id != null && pending.has(msg.id)) { + pending.get(msg.id).resolve(msg); + pending.delete(msg.id); + } +}); +child.on('exit', (code, signal) => { + for (const { reject } of pending.values()) + reject(new Error(`helper exited code=${code} signal=${signal ?? 'none'}`)); + pending.clear(); +}); + +// Baseline host deadlines per issue: handshake 10s, request 20s. +function call(method, params = {}, timeoutMs = 20000) { + return new Promise((resolve, reject) => { + const id = nextId++; + pending.set(id, { resolve, reject }); + child.stdin.write(JSON.stringify({ jsonrpc: '2.0', id, method, params }) + '\n'); + setTimeout(() => { + if (pending.delete(id)) reject(new Error(`timeout waiting for ${method}`)); + }, timeoutMs); + }); +} + +const report = []; +const t0 = Date.now(); +let failures = 0; +const check = (name, ok, note = '') => { + report.push(`${ok ? 'PASS' : 'FAIL'} ${name}${note ? ' — ' + note : ''}`); + if (!ok) failures++; +}; +const patternHits = (n) => + ['Value', 'Invoke', 'Toggle', 'SelectionItem'].filter((p) => n.patterns?.includes(p)); + +try { + // 1. handshake (10s baseline) + const hello = await call('initialize', {}, 10000); + check( + 'initialize', + hello.result?.protocol === 'maka.cu.windows/0', + `protocol=${hello.result?.protocol}`, + ); + + // 2. window list + const list = await call('list_windows'); + const windows = (list.result?.windows ?? []).filter( + (w) => w.title === 'maka-cu-windows-fixture' && !w.isOffscreen, + ); + check('list_windows (fixture-only)', windows.length > 0, `fixture=${windows.length}`); + console.log( + ' windows:', + windows + .slice(0, 6) + .map((w) => `#${w.hwnd} "${w.title}" (pid ${w.pid})`) + .join('\n ') || ' (none)', + ); + if (windows.length === 0) throw new Error('no visible top-level window to observe'); + + const candidates = windows.filter((w) => String(w.hwnd) === hwndOverride); + if (candidates.length !== 1) + throw new Error('supplied hwnd is not the identified fixture window'); + + // 3+4. observe + one semantic action — first verified target wins + const attempts = []; + let chosen = null; + for (const w of candidates) { + if (chosen) break; + const obs = await call('observe', { hwnd: w.hwnd }); + if (obs.error) { + attempts.push(`observe #${w.hwnd} "${w.title}" -> ${obs.error.message}`); + continue; + } + const t = obs.result.tree; + const actionable = (t.nodes ?? []).find((n) => patternHits(n).length > 0 && n.isEnabled); + if (!actionable) { + attempts.push(`#${w.hwnd} "${w.title}": no actionable element (nodes=${t.nodeCount})`); + continue; + } + const isValue = actionable.patterns.includes('Value'); + const res = await call( + 'act', + isValue + ? { + snapshotId: obs.result.snapshotId, + elementToken: actionable.token, + op: 'set_value', + value: `spike-${Date.now() % 10000}`, + } + : { + snapshotId: obs.result.snapshotId, + elementToken: actionable.token, + op: 'click_element', + }, + ); + const o = res.result?.outcome; + const label = isValue ? 'act set_value (ValuePattern)' : 'act click_element'; + const line = `${label} ${actionable.controlType} "${actionable.name}" path=${o?.path} status=${o?.status}${o?.reason ? ` reason=${o.reason}` : ''} verification=${o?.verification}`; + const again = await call('act', { + snapshotId: obs.result.snapshotId, + elementToken: actionable.token, + op: 'set_value', + value: 'x', + }); + const spent = again.error?.message === 'snapshot_spent_or_unknown'; + attempts.push( + `${line} | snapshot spent: ${spent ? 'ok' : (again.error?.message ?? 'MISSING (accepted!)')}`, + ); + if (o?.status === 'verified' && spent) + chosen = { w, tree: t, snapshot: obs.result, outcome: o }; + } + + for (const a of attempts) report.push(`NOTE ${a}`); + if (chosen) { + check( + 'observe (bounded tree)', + chosen.tree.nodeCount > 0, + `target=#${chosen.w.hwnd} nodes=${chosen.tree.nodeCount} truncated=${chosen.tree.truncated} elapsed=${chosen.tree.elapsedMs}ms`, + ); + check( + 'observe target identity', + idOk(chosen.snapshot.target), + JSON.stringify(chosen.snapshot.target), + ); + check( + 'semantic action verified', + true, + `${chosen.outcome.path} -> ${chosen.outcome.verification}`, + ); + } else { + check( + 'semantic action verified', + false, + 'no candidate produced a verified action (helper stayed fail-closed)', + ); + } + + // 5. cancel shape + const cancel = await call('$/cancel'); + check('$/cancel', cancel.result?.cancelled === true); + + // 6. shutdown + const bye = await call('shutdown'); + check('shutdown', bye.result?.ok === true); + const exitCode = await new Promise((resolve) => { + child.on('exit', (code) => resolve(code)); + setTimeout(() => resolve('timeout'), 5000); + }); + check('helper exits after shutdown', exitCode === 0, `exit=${exitCode}`); +} catch (err) { + check('driver flow', false, err.message); + child.kill(); +} + +const dt = Date.now() - t0; +console.log('\n--- spike report ---'); +for (const line of report) console.log(line); +console.log(`total ${dt}ms, failures=${failures}`); +process.exitCode = failures > 0 ? 1 : 0; + +function idOk(target) { + return ( + !!target && + typeof target.hwnd === 'number' && + typeof target.pid === 'number' && + !!target.processStartTimeUtc + ); +} diff --git a/experiments/maka-cu-windows/fixture/HangWindowFixture/HangWindowFixture.csproj b/experiments/maka-cu-windows/fixture/HangWindowFixture/HangWindowFixture.csproj new file mode 100644 index 0000000000..12deeb7e0f --- /dev/null +++ b/experiments/maka-cu-windows/fixture/HangWindowFixture/HangWindowFixture.csproj @@ -0,0 +1,32 @@ + + + + + + Exe + net8.0-windows10.0.22621.0 + true + enable + enable + HangWindowFixture + maka-cu-windows-fixture + + + diff --git a/experiments/maka-cu-windows/fixture/HangWindowFixture/Program.cs b/experiments/maka-cu-windows/fixture/HangWindowFixture/Program.cs new file mode 100644 index 0000000000..c85aa5e78f --- /dev/null +++ b/experiments/maka-cu-windows/fixture/HangWindowFixture/Program.cs @@ -0,0 +1,234 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +// Deterministic test fixture for the maka-cu-windows spike (checks 4-6 and +// identity cases). A WinForms window with a TextBox (ValuePattern), a Button +// (InvokePattern) and a static Label. Stdin line protocol: +// freeze - block the UI thread (message pump stops -> window becomes +// Not Responding -> UIA provider calls block: a REAL blocked +// provider call, not a sleep inside the helper) +// unfreeze - resume the UI thread +// cover - show a larger, topmost solid-red window over the main window +// uncover - hide the cover +// recreate - close + recreate the main window (new HWND, new generation) +// shutdown - exit +// stdout: +// READY +// SIZE (physical window rect via GetWindowRect, after recreate too) + +using System.Runtime.InteropServices; +using System.Windows.Forms; + +var fixture = new Fixture(); +fixture.Run(); + +sealed class Fixture +{ + const string WindowTitle = "maka-cu-windows-fixture"; + + Form _form = null!; + Form? _cover; + readonly ManualResetEventSlim _freezeEvent = new(false); + + [DllImport("user32.dll")] + [return: MarshalAs(UnmanagedType.Bool)] + static extern bool GetWindowRect(IntPtr hWnd, out RECT rect); + + [DllImport("user32.dll")] + [return: MarshalAs(UnmanagedType.Bool)] + static extern bool SetProcessDpiAwarenessContext(IntPtr value); + + [DllImport("dwmapi.dll")] + static extern int DwmGetWindowAttribute(IntPtr hWnd, int attribute, out RECT value, int valueSize); + + [StructLayout(LayoutKind.Sequential)] + struct RECT { public int Left, Top, Right, Bottom; } + + public void Run() + { + // WGC reports physical pixels. Set per-monitor awareness before any + // WinForms handle is created so SIZE and capture ContentSize agree. + _ = SetProcessDpiAwarenessContext(new IntPtr(-4)); // PER_MONITOR_AWARE_V2 + CreateForm(); + // The independent ApplicationContext does not auto-show a form. + _form.Show(); + + var reader = new Thread(() => + { + while (Console.ReadLine() is { } line) + { + try + { + switch (line.Trim()) + { + case "freeze": + // Runs on the UI thread; the pump stops while it waits. + _freezeEvent.Reset(); + _form.BeginInvoke(() => + { + Console.WriteLine("FROZEN"); + Console.Out.Flush(); + _freezeEvent.Wait(); + }); + break; + case "unfreeze": + _freezeEvent.Set(); + Console.WriteLine("UNFROZEN"); + Console.Out.Flush(); + break; + case "cover": OnUi(ShowCover); break; + case "uncover": OnUi(HideCover); break; + case "recreate": OnUi(Recreate); break; + case "shutdown": OnUi(() => Application.Exit()); return; + } + } + catch (Exception ex) + { + Console.WriteLine($"CMD_ERROR {ex.GetType().Name}: {ex.Message}"); + Console.Out.Flush(); + } + } + }) { IsBackground = true, Name = "cmd-reader" }; + reader.Start(); + + // Use an independent application context so a test recreation can + // dispose the original form without ending the message loop. + Application.Run(new ApplicationContext()); + } + + void OnUi(Action a) + { + Form f; + lock (this) { f = _form; } + f.Invoke(a); + } + + void CreateForm() + { + var form = new Form + { + Text = WindowTitle, + Width = 480, + Height = 360, + StartPosition = FormStartPosition.CenterScreen, + FormBorderStyle = FormBorderStyle.FixedDialog, + MaximizeBox = false, + MinimizeBox = false, + BackColor = Color.White, + }; + var label = new Label + { + Text = "maka-cu-windows-fixture static content", + AutoSize = true, + Location = new Point(16, 16), + }; + var input = new TextBox + { + Name = "fixture-input", + Text = "", + Width = 300, + Location = new Point(16, 48), + }; + var button = new Button + { + Name = "fixture-button", + Text = "fixture-button", + Width = 120, + Location = new Point(16, 88), + }; + var sentinel = new Panel + { + Name = "fixture-sentinel", + BackColor = Color.LimeGreen, + Width = 80, + Height = 50, + Location = new Point(360, 220), + }; + form.Controls.Add(label); + form.Controls.Add(input); + form.Controls.Add(button); + form.Controls.Add(sentinel); + form.FormClosed += (sender, _) => + { + // Recreate disposes the old form after installing the replacement; + // only the current top-level form owns application shutdown. + lock (this) + { + if (ReferenceEquals(sender, _form)) Application.Exit(); + } + }; + + lock (this) { _form = form; } + form.Shown += (_, _) => PrintSize(); + } + + void PrintSize() + { + IntPtr h = _form.Handle; + if (GetWindowRect(h, out var r)) + { + Console.WriteLine($"READY {Environment.ProcessId} {h.ToInt64()}"); + Console.WriteLine($"SIZE {r.Right - r.Left} {r.Bottom - r.Top}"); + // WGC excludes invisible resize borders. Keep this physical + // capture contract separate from the outer GetWindowRect value. + if (DwmGetWindowAttribute(h, 9, out var frame, Marshal.SizeOf()) == 0) + Console.WriteLine($"CAPTURE_SIZE {frame.Right - frame.Left} {frame.Bottom - frame.Top}"); + Console.Out.Flush(); + } + } + + void Recreate() + { + var old = _form; + old.Hide(); + CreateForm(); + _form.Show(); + old.Dispose(); + } + + void ShowCover() + { + if (_cover is not null && !_cover.IsDisposed) return; + var main = _form; + var b = main.Bounds; + b.Inflate(60, 60); + _cover = new Form + { + Text = "maka-cu-windows-fixture-cover", + StartPosition = FormStartPosition.Manual, + Bounds = b, + BackColor = Color.Red, + TopMost = true, + ShowInTaskbar = false, + }; + _cover.Show(main); + _cover.BringToFront(); + Console.WriteLine("COVERED"); + Console.Out.Flush(); + } + + void HideCover() + { + if (_cover is null || _cover.IsDisposed) return; + _cover.Close(); + _cover = null; + Console.WriteLine("UNCOVERED"); + Console.Out.Flush(); + } +} diff --git a/experiments/maka-cu-windows/lifecycle-driver.mjs b/experiments/maka-cu-windows/lifecycle-driver.mjs new file mode 100644 index 0000000000..cf9721af31 --- /dev/null +++ b/experiments/maka-cu-windows/lifecycle-driver.mjs @@ -0,0 +1,695 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +// Lifecycle driver for the maka-cu-windows spike (checks 4-6 + identity + +// parent-death), per the updated apache/maka#4318. Requires the built helper +// exe and the built fixture exe: +// +// node lifecycle-driver.mjs +// +// Scenarios (each prints PASS/FAIL): +// C4 WGC CreateForWindow capture + occlusion (cover must not leak pixels) +// C5a cancellation after dispatch: request settles with actual outcome +// C5b cancellation before dispatch: deterministic via debug_sleep queueing, +// typed cancelled outcome, no mutation (textbox stays empty) +// C5c control plane responsive while provider call blocked (frozen fixture) +// C6 hung-provider recovery: supervisor kills helper, restart advances +// generation, old snapshots invalid, fresh observe/act/readback works +// ID window recreation -> old snapshot fails closed (stale target) +// PD parent death: abrupt kill and stdin-EOF both exit the helper within +// the declared deadline + +import { spawn } from 'node:child_process'; +import { createInterface } from 'node:readline/promises'; +import { inflateSync } from 'node:zlib'; +import { fileURLToPath } from 'node:url'; + +const helperExe = process.argv[2]; +const fixtureExe = process.argv[3]; +if (!helperExe || !fixtureExe) { + console.error('usage: node lifecycle-driver.mjs '); + process.exit(2); +} + +const HANDSHAKE_MS = 10000; +const REQUEST_MS = 20000; +const CANCEL_GRACE_MS = 2000; +const FIXTURE_TITLE = 'maka-cu-windows-fixture'; + +const report = []; +let failures = 0; +const ownedHelpers = new Set(); +const ownedFixtures = new Set(); +const check = (name, ok, note = '') => { + report.push(`${ok ? 'PASS' : 'FAIL'} ${name}${note ? ' — ' + note : ''}`); + if (!ok) failures++; +}; + +function makeHelper() { + const child = spawn(helperExe, [], { stdio: ['pipe', 'pipe', 'inherit'] }); + ownedHelpers.add(child); + child.once('exit', () => ownedHelpers.delete(child)); + const rl = createInterface({ input: child.stdout }); + let nextId = 1; + const pending = new Map(); + rl.on('line', (line) => { + const msg = JSON.parse(line); + if (msg.id != null && pending.has(msg.id)) { + pending.get(msg.id).resolve(msg); + pending.delete(msg.id); + } + }); + child.on('exit', (code, signal) => { + for (const { reject } of pending.values()) + reject(new Error(`helper exited code=${code} signal=${signal ?? 'none'}`)); + pending.clear(); + }); + const call = (method, params = {}, timeoutMs = REQUEST_MS) => + new Promise((resolve, reject) => { + const id = nextId++; + pending.set(id, { resolve, reject }); + if (child.stdin.destroyed) { + reject(new Error('stdin closed')); + return; + } + child.stdin.write(JSON.stringify({ jsonrpc: '2.0', id, method, params }) + '\n'); + setTimeout(() => { + if (pending.delete(id)) reject(new Error(`timeout waiting for ${method}`)); + }, timeoutMs); + }); + const kill = async (label, deadlineMs = CANCEL_GRACE_MS) => { + const t0 = Date.now(); + const exited = new Promise((res) => child.once('exit', (code) => res(code))); + child.kill(); + const code = await Promise.race([ + exited, + new Promise((res) => setTimeout(() => res('timeout'), deadlineMs + 3000)), + ]); + return { code, elapsedMs: Date.now() - t0 }; + }; + return { child, call, kill, nextId: () => nextId }; +} + +function makeFixture() { + const child = spawn(fixtureExe, [], { stdio: ['pipe', 'pipe', 'inherit'] }); + ownedFixtures.add(child); + child.once('exit', () => ownedFixtures.delete(child)); + const rl = createInterface({ input: child.stdout }); + let size = null; + let captureSize = null; + let identity = null; + let firstReady; + const ready = new Promise((res) => { + firstReady = res; + }); + const sizeP = new Promise((res) => { + const check = () => { + if (size) res(size); + }; + child.on('fixture-size', check); + }); + const captureSizeP = new Promise((res) => { + const check = () => { + if (captureSize) res(captureSize); + }; + child.on('fixture-capture-size', check); + }); + const lineWaiters = []; + const newIdentityWaiters = []; + rl.on('line', (l) => { + const m = l.match(/^READY (\d+) (\d+)$/); + if (m) { + const next = { pid: +m[1], hwnd: +m[2] }; + const previous = identity; + identity = next; + if (!previous) firstReady(next); + for (let i = newIdentityWaiters.length - 1; i >= 0; i--) { + if (!previous || next.hwnd === previous.hwnd) continue; + const waiter = newIdentityWaiters.splice(i, 1)[0]; + waiter(next); + } + } + const sm = l.match(/^SIZE (\d+) (\d+)/); + if (sm) { + size = { w: +sm[1], h: +sm[2] }; + child.emit('fixture-size'); + } + const cm = l.match(/^CAPTURE_SIZE (\d+) (\d+)/); + if (cm) { + captureSize = { w: +cm[1], h: +cm[2] }; + child.emit('fixture-capture-size'); + } + for (let i = lineWaiters.length - 1; i >= 0; i--) { + if (!l.startsWith(lineWaiters[i].prefix)) continue; + const waiter = lineWaiters.splice(i, 1)[0]; + waiter.resolve(l); + } + }); + const waitForLine = (prefix, timeoutMs = 3000) => + new Promise((resolve, reject) => { + const waiter = { prefix, resolve, reject }; + lineWaiters.push(waiter); + setTimeout(() => { + const i = lineWaiters.indexOf(waiter); + if (i >= 0) { + lineWaiters.splice(i, 1); + reject(new Error(`fixture ACK timeout: ${prefix}`)); + } + }, timeoutMs); + }); + const waitForNewIdentity = (oldHwnd, timeoutMs = 5000) => + new Promise((resolve, reject) => { + const waiter = (next) => { + if (next.hwnd !== oldHwnd) resolve(next); + }; + newIdentityWaiters.push(waiter); + setTimeout(() => { + const i = newIdentityWaiters.indexOf(waiter); + if (i >= 0) { + newIdentityWaiters.splice(i, 1); + reject(new Error('fixture recreate READY timeout')); + } + }, timeoutMs); + }); + const cmd = async (c) => { + if (c === 'shutdown') { + child.stdin.write(c + '\n'); + return null; + } + const expected = { + freeze: 'FROZEN', + unfreeze: 'UNFROZEN', + cover: 'COVERED', + uncover: 'UNCOVERED', + }[c]; + const ack = expected ? waitForLine(expected) : null; + const recreated = c === 'recreate' ? waitForNewIdentity(identity?.hwnd) : null; + child.stdin.write(c + '\n'); + if (recreated) { + identity = await recreated; + return identity; + } + if (ack) await ack; + return null; + }; + return { + child, + ready, + size: () => size, + captureSize: () => captureSize, + identity: () => identity, + sizeP, + captureSizeP, + cmd, + waitForNewIdentity, + }; +} + +const wait = (ms) => new Promise((r) => setTimeout(r, ms)); +const sleep = wait; + +function findNode(tree, pred) { + return (tree?.nodes ?? []).find(pred); +} +async function findFixture(h, identity, attempts = 12) { + for (let i = 0; i < attempts; i++) { + const listed = await h.call('list_windows'); + const found = (listed.result?.windows ?? []).find( + (w) => w.title === FIXTURE_TITLE && w.pid === identity.pid && w.hwnd === identity.hwnd, + ); + if (found) return found; + await sleep(100); + } + return null; +} +function decodePng(frame) { + const b = Buffer.from(frame.base64, 'base64'); + if (!b.subarray(0, 8).equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]))) return null; + let width = 0, + height = 0, + colorType = 0, + bitDepth = 0; + const idat = []; + for (let p = 8; p + 12 <= b.length; ) { + const n = b.readUInt32BE(p); + const type = b.toString('ascii', p + 4, p + 8); + const data = b.subarray(p + 8, p + 8 + n); + p += n + 12; + if (type === 'IHDR') { + width = data.readUInt32BE(0); + height = data.readUInt32BE(4); + bitDepth = data[8]; + colorType = data[9]; + } + if (type === 'IDAT') idat.push(data); + } + if (bitDepth !== 8 || colorType !== 6 || width <= 0 || height <= 0) return null; + const raw = inflateSync(Buffer.concat(idat)); + const stride = width * 4; + let green = 0; + for (let y = 0; y < height; y++) { + const row = y * (stride + 1); + if (raw[row] !== 0) return null; // this spike encoder deliberately uses filter 0 + for (let x = 0; x < width; x++) { + const i = row + 1 + x * 4; + if ( + raw[i] >= 35 && + raw[i] <= 70 && + raw[i + 1] >= 185 && + raw[i + 1] <= 220 && + raw[i + 2] >= 35 && + raw[i + 2] <= 70 && + raw[i + 3] >= 240 + ) + green++; + } + } + return { width, height, sentinelPixels: green }; +} +// Cached render nodes carry no patterns/value; the actionable live nodes do. +const inputNode = (tree) => + findNode(tree, (n) => n.automationId === 'fixture-input' && (n.patterns?.length ?? 0) > 0); + +try { + // ---- fixture up --------------------------------------------------------- + const fx = makeFixture(); + const fixtureIdentity = await fx.ready; + await fx.sizeP; + await fx.captureSizeP; + check( + 'fixture ready', + fx.size() !== null && fx.captureSize() !== null && fixtureIdentity.hwnd > 0, + `pid=${fixtureIdentity.pid} hwnd=#${fixtureIdentity.hwnd} outer=${fx.size().w}x${fx.size().h} capture=${fx.captureSize().w}x${fx.captureSize().h}`, + ); + + // ---- helper up ---------------------------------------------------------- + const h1 = makeHelper(); + const hello = await h1.call('initialize', {}, HANDSHAKE_MS); + const gen1 = hello.result?.generation; + check( + 'initialize', + hello.result?.protocol === 'maka.cu.windows/0', + `generation=${gen1} signature=${hello.result?.signature} ready=${hello.result?.distributionReady}`, + ); + + const list = await h1.call('list_windows'); + const fxWin = (list.result?.windows ?? []).find( + (w) => + w.title === FIXTURE_TITLE && w.pid === fixtureIdentity.pid && w.hwnd === fixtureIdentity.hwnd, + ); + check('list_windows finds fixture', !!fxWin, fxWin ? `hwnd=#${fxWin.hwnd}` : 'not found'); + if (!fxWin) { + await h1.child.kill(); + process.exit(1); + } + + const obs1 = await h1.call('observe', { hwnd: fxWin.hwnd }); + check( + 'observe fixture', + !obs1.error, + obs1.error?.message ?? `nodes=${obs1.result.tree.nodeCount}`, + ); + const snap1 = obs1.result.snapshotId; + const target1 = obs1.result.target; + const gen1w = target1.windowGeneration; + check( + 'target identity (hwnd+pid+start+windowGeneration)', + !!target1.processStartTimeUtc && typeof gen1w === 'string' && gen1w.length === 16, + JSON.stringify(target1), + ); + const inputNode1 = inputNode(obs1.result.tree); + check( + 'fixture textbox actionable (ValuePattern)', + !!inputNode1 && inputNode1.patterns?.includes('Value'), + inputNode1?.controlType ?? 'missing', + ); + + // ---- C4: WGC capture + occlusion ---------------------------------------- + const cap0 = await h1.call('capture', { hwnd: fxWin.hwnd, windowGeneration: gen1w }); + const c0 = cap0.result; + const baselineFrame = c0?.frame; + const baselinePng = baselineFrame ? decodePng(baselineFrame) : null; + const sizeMatch0 = + c0?.frame?.width === fx.captureSize().w && c0?.frame?.height === fx.captureSize().h; + check( + 'C4 capture CreateForWindow', + c0?.status === 'available' && c0?.path === 'wgc_createforwindow', + `response=${JSON.stringify(cap0)} path=${c0?.path} size=${c0?.frame?.width}x${c0?.frame?.height} bytes=${c0?.frame?.bytes} elapsed=${c0?.frame?.elapsedMs}ms`, + ); + check( + 'C4 frame is PNG base64', + c0?.frame?.format === 'png' && + typeof c0?.frame?.base64 === 'string' && + Buffer.from(c0.frame.base64, 'base64') + .subarray(0, 8) + .equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10])), + `format=${c0?.frame?.format} payload=${c0?.frame?.base64?.length ?? 0}`, + ); + check( + 'C4 frame size == DWM capture bounds', + sizeMatch0, + `capture=${c0?.frame?.width}x${c0?.frame?.height} expected=${fx.captureSize().w}x${fx.captureSize().h} outer=${fx.size().w}x${fx.size().h}`, + ); + check( + 'C4 decoded PNG contains fixture sentinel', + !!baselinePng && baselinePng.sentinelPixels > 500, + baselinePng + ? `${baselinePng.width}x${baselinePng.height} sentinelPixels=${baselinePng.sentinelPixels}` + : 'PNG decode failed', + ); + + if (baselineFrame) { + await fx.cmd('cover'); + const cap1 = await h1.call('capture', { hwnd: fxWin.hwnd, windowGeneration: gen1w }); + const c1 = cap1.result; + const coveredPng = c1?.frame ? decodePng(c1.frame) : null; + const occluded = + c1?.status === 'available' && + c1?.frame && + coveredPng && + baselinePng && + c1.frame.width === baselineFrame.width && + c1.frame.height === baselineFrame.height && + coveredPng.sentinelPixels === baselinePng.sentinelPixels; + check( + 'C4 occlusion: cover does not leak pixels', + occluded, + `size=${c1?.frame?.width}x${c1?.frame?.height} sentinel=${coveredPng?.sentinelPixels ?? 'decode-failed'} baseline=${baselinePng?.sentinelPixels ?? 'decode-failed'} (stable fixture pixels)`, + ); + await fx.cmd('uncover'); + const cap2 = await h1.call('capture', { hwnd: fxWin.hwnd, windowGeneration: gen1w }); + const afterPng = cap2.result?.frame ? decodePng(cap2.result.frame) : null; + check( + 'C4 capture stable after uncover', + !!afterPng && afterPng.sentinelPixels === baselinePng?.sentinelPixels, + `sentinel=${afterPng?.sentinelPixels ?? 'decode-failed'} baseline=${baselinePng?.sentinelPixels ?? 'decode-failed'}`, + ); + } else { + check( + 'C4 occlusion: cover does not leak pixels', + false, + 'baseline capture unavailable; occlusion not exercised', + ); + } + + // ---- C5a: cancellation after dispatch settles with actual outcome -------- + // The fixture-only delay begins after ValuePattern.SetValue and readback, + // making this a real in-flight post-dispatch cancellation. + const setRequestId = h1.nextId(); + const setP = h1.call('act', { + snapshotId: snap1, + elementToken: inputNode1.token, + op: 'set_value', + value: 'settled-1', + debugPostDispatchDelayMs: 700, + }); + await sleep(120); + const cancelAfterDispatch = await h1.call('$/cancel', { id: setRequestId }); + check( + 'C5a $/cancel reaches in-flight op', + cancelAfterDispatch.result?.cancelled === true && + cancelAfterDispatch.result?.pendingRequestId !== null, + `pending=${cancelAfterDispatch.result?.pendingRequestId}`, + ); + const setRes = await setP; + const sOut = setRes.result?.outcome; + check( + 'C5a original request settles actual outcome', + sOut?.status === 'verified' && + sOut?.verification === 'value_readback_match' && + sOut?.effect === 'value_set', + `status=${sOut?.status} verification=${sOut?.verification} effect=${sOut?.effect}`, + ); + const obsAfter = await h1.call('observe', { hwnd: fxWin.hwnd }); + const inputAfter = inputNode(obsAfter.result.tree); + check( + 'C5a mutation visible via readback', + inputAfter?.value === 'settled-1', + `value="${inputAfter?.value}"`, + ); + const snapA = obsAfter.result.snapshotId; + const inputA = inputAfter; + + // ---- C5b: cancellation before dispatch (deterministic) ------------------- + // Block the lane briefly with debug_sleep, queue an act behind it, cancel. + const sleepP = h1.call('debug_sleep', { ms: 500 }); + await sleep(120); // ensure debug_sleep is running on the lane + const actRequestId = h1.nextId(); + const actP = h1.call('act', { + snapshotId: snapA, + elementToken: inputA.token, + op: 'set_value', + value: 'mutated?', + }); + await sleep(80); + const cancelBusy = await h1.call('$/cancel', { id: actRequestId }); + check( + 'C5b $/cancel while op queued', + cancelBusy.result?.cancelled === true && cancelBusy.result?.pendingRequestId !== null, + `pending=${cancelBusy.result?.pendingRequestId}`, + ); + const actSettled = await actP; + const actOut = actSettled.result?.outcome; + check( + 'C5b queued act settles refused (cancelled before dispatch)', + actOut?.status === 'refused' && actOut?.reason === 'cancelled_before_dispatch', + `status=${actOut?.status} reason=${actOut?.reason}`, + ); + await sleepP; + const obsNoMut = await h1.call('observe', { hwnd: fxWin.hwnd }); + const inputNoMut = inputNode(obsNoMut.result.tree); + check( + 'C5b no mutation occurred', + inputNoMut?.value === 'settled-1', + `value="${inputNoMut?.value}" (must remain settled-1)`, + ); + + // ---- C5c + C6: blocked provider -> cancel grace -> kill -> restart -------- + const preHang = await h1.call('observe', { hwnd: fxWin.hwnd }); + const preHangInput = inputNode(preHang.result.tree); + await fx.cmd('freeze'); // FROZEN is emitted after the UI thread enters wait + let blockedSettled = false; + const blockedObs = h1.call('observe', { hwnd: fxWin.hwnd }).then( + (v) => { + blockedSettled = true; + return v; + }, + (e) => { + blockedSettled = true; + return e; + }, + ); + await sleep(400); + const tCancel = Date.now(); + const cancelBlocked = await h1.call('$/cancel'); + check( + 'C5c control plane responsive while provider blocked', + cancelBlocked.result?.cancelled === true && Date.now() - tCancel < CANCEL_GRACE_MS, + `ack in ${Date.now() - tCancel}ms`, + ); + // blocked observe cannot settle within grace -> supervisor terminates helper + await sleep(CANCEL_GRACE_MS); + check( + 'C6 blocked request remains unsettled through grace', + !blockedSettled, + `settled=${blockedSettled}`, + ); + let fixtureAliveDuringHang = false; + try { + process.kill(fixtureIdentity.pid, 0); + fixtureAliveDuringHang = true; + } catch {} + check( + 'C6 target fixture survives blocked helper', + fixtureAliveDuringHang, + `pid=${fixtureIdentity.pid}`, + ); + const killed = await h1.kill('hung-helper'); + check( + 'C6 helper terminated after cancel grace', + killed.code !== 'timeout', + `exit=${killed.code} in ${killed.elapsedMs}ms`, + ); + await blockedObs; + await fx.cmd('unfreeze'); + + // restart: generation advances, old snapshot invalid, fresh work succeeds + const h2 = makeHelper(); + const hello2 = await h2.call('initialize', {}, HANDSHAKE_MS); + const gen2 = hello2.result?.generation; + check('C6 restart advances helper generation', gen2 !== gen1, `gen ${gen1} -> ${gen2}`); + const obsOld = await h2.call('act', { + snapshotId: preHang.result.snapshotId, + elementToken: preHangInput.token, + op: 'set_value', + value: 'x', + }); + check( + 'C6 old snapshot invalid after restart', + obsOld.error?.message === 'snapshot_spent_or_unknown', + obsOld.error?.message ?? 'accepted(!)', + ); + const list2 = await h2.call('list_windows'); + const fxWin2 = (list2.result?.windows ?? []).find( + (w) => + w.title === FIXTURE_TITLE && w.pid === fixtureIdentity.pid && w.hwnd === fixtureIdentity.hwnd, + ); + const obs2 = await h2.call('observe', { hwnd: fxWin2.hwnd }); + const input2 = inputNode(obs2.result.tree); + const act2 = await h2.call('act', { + snapshotId: obs2.result.snapshotId, + elementToken: input2.token, + op: 'set_value', + value: 'after-restart', + }); + check( + 'C6 fresh observe/act/readback after restart', + act2.result?.outcome?.status === 'verified' && + act2.result?.outcome?.verification === 'value_readback_match', + `status=${act2.result?.outcome?.status}`, + ); + const obs2b = await h2.call('observe', { hwnd: fxWin2.hwnd }); + const input2b = inputNode(obs2b.result.tree); + const mixedToken = await h2.call('act', { + snapshotId: obs2b.result.snapshotId, + elementToken: input2.token, + op: 'set_value', + value: 'must-refuse', + }); + check( + 'C6 cross-snapshot token fails closed', + mixedToken.error?.message === 'element_token_unknown_in_snapshot', + mixedToken.error?.message ?? 'accepted(!)', + ); + + // ---- ID: window recreation fails closed ---------------------------------- + const obsId = await h2.call('observe', { hwnd: fxWin2.hwnd }); + const snapId3 = obsId.result.snapshotId; + const input3 = inputNode(obsId.result.tree); + const oldHwnd = fixtureIdentity.hwnd; + await fx.cmd('recreate'); + const recreatedIdentity = fx.identity(); + check( + 'ID recreate returned a different HWND', + recreatedIdentity.pid === fixtureIdentity.pid && recreatedIdentity.hwnd !== oldHwnd, + `old=#${oldHwnd} new=#${recreatedIdentity.hwnd}`, + ); + const act3 = await h2.call('act', { + snapshotId: snapId3, + elementToken: input3.token, + op: 'set_value', + value: 'should-fail', + }); + check( + 'ID window recreation -> stale target fails closed', + act3.error?.message === 'stale_target_revalidate_failed', + act3.error?.message ?? 'accepted(!)', + ); + const fxWin3 = await findFixture(h2, recreatedIdentity); + check( + 'ID recreated fixture remains alive', + !!fxWin3, + fxWin3 ? `hwnd=#${fxWin3.hwnd}` : 'not found', + ); + const obs3 = fxWin3 + ? await h2.call('observe', { hwnd: fxWin3.hwnd }) + : { error: { message: 'fixture_not_found' } }; + check( + 'ID new window requires new explicit selection', + !obs3.error && obs3.result.snapshotId !== snapId3, + `new snapshot=${obs3.result?.snapshotId !== snapId3}`, + ); + + // ---- PD: parent death ---------------------------------------------------- + const shutdown2 = await h2.call('shutdown'); + check('PD setup helper shutdown', shutdown2.result?.ok === true); + await new Promise((res) => h2.child.once('exit', res)); + // Run the helper under a short-lived host process. That host starts a real + // blocked observe, then exits abruptly; this driver only observes helper + // disappearance and never kills that helper PID itself. + await fx.cmd('freeze'); + const probePath = fileURLToPath(new URL('./parent-probe.mjs', import.meta.url)); + const probe = spawn(process.execPath, [probePath, helperExe, String(fxWin3.hwnd)], { + stdio: ['ignore', 'pipe', 'inherit'], + }); + let probeOutput = ''; + probe.stdout.on('data', (b) => { + probeOutput += b.toString(); + }); + const probeExit = await new Promise((res) => probe.once('exit', (c) => res(c))); + const helperPidMatch = probeOutput.match(/HELPER (\d+)/); + const abruptPid = helperPidMatch ? Number(helperPidMatch[1]) : null; + let abruptAlive = abruptPid !== null; + const pdStart = Date.now(); + while (abruptAlive && Date.now() - pdStart < CANCEL_GRACE_MS + 1500) { + try { + process.kill(abruptPid, 0); + await sleep(100); + } catch { + abruptAlive = false; + } + } + check( + 'PD probe observed handshake and blocked observe', + probeExit === 77 && + /HOST_STAGE initialized/.test(probeOutput) && + /HOST_STAGE observe_sent/.test(probeOutput), + `hostExit=${probeExit} stages=${probeOutput.replaceAll('\\n', ' ').trim()}`, + ); + check( + 'PD abrupt host death exits blocked helper', + probeExit === 77 && !abruptAlive, + `helper=${abruptPid ?? 'unknown'} residual=${abruptAlive} elapsed=${Date.now() - pdStart}ms`, + ); + await fx.cmd('unfreeze'); + const h3 = makeHelper(); + await h3.call('initialize', {}, HANDSHAKE_MS); + const eofBefore = Date.now(); + h3.child.stdin.end(); + const eofExit = await new Promise((res) => { + h3.child.once('exit', (c) => res(c)); + setTimeout(() => res('timeout'), CANCEL_GRACE_MS + 2000); + }); + check( + 'PD stdin EOF exits helper within deadline', + eofExit !== 'timeout', + `exit=${eofExit} in ${Date.now() - eofBefore}ms`, + ); + + // ---- shutdown ------------------------------------------------------------ + fx.cmd('shutdown'); + await new Promise((r) => { + fx.child.once('exit', r); + setTimeout(r, 3000); + }); +} catch (err) { + check('lifecycle driver flow', false, err.message); +} + +// Every run owns only this fixture and its helpers. Clean up even when a +// capture/provider call aborts the flow, so a failed experiment cannot leave +// an executable locked or a visible fixture behind. +for (const child of [...ownedHelpers, ...ownedFixtures]) { + if (!child.killed) child.kill(); +} + +console.log('\n--- lifecycle spike report ---'); +for (const line of report) console.log(line); +console.log(`failures=${failures}`); +process.exitCode = failures > 0 ? 1 : 0; diff --git a/experiments/maka-cu-windows/parent-probe.mjs b/experiments/maka-cu-windows/parent-probe.mjs new file mode 100644 index 0000000000..865d9dde20 --- /dev/null +++ b/experiments/maka-cu-windows/parent-probe.mjs @@ -0,0 +1,70 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +// Short lived host for PD. The fixture is frozen by lifecycle-driver before +// this probe starts, so observe is a real blocked UIA provider operation. +// The probe proves initialize and observe dispatch, then exits without +// touching the helper PID. stdin/stdout close with the host process. +import { spawn } from 'node:child_process'; +import { createInterface } from 'node:readline'; + +const helperExe = process.argv[2]; +const hwnd = Number(process.argv[3]); +if (!helperExe || !Number.isInteger(hwnd) || hwnd <= 0) process.exit(2); + +const helper = spawn(helperExe, [], { stdio: ['pipe', 'pipe', 'ignore'] }); +console.log(`HELPER ${helper.pid}`); +const rl = createInterface({ input: helper.stdout }); +let initialized = false; +let observeSent = false; +let observeSettled = false; +let stageTimer; +rl.on('line', (line) => { + let msg; + try { + msg = JSON.parse(line); + } catch { + return; + } + if (msg.id === 1 && msg.result) { + initialized = true; + console.log('HOST_STAGE initialized'); + helper.stdin.write( + JSON.stringify({ jsonrpc: '2.0', id: 2, method: 'observe', params: { hwnd } }) + '\n', + ); + observeSent = true; + console.log('HOST_STAGE observe_sent'); + // Give the actual blocked provider call a bounded interval. Startup has + // a separate generous deadline for cold single-file extraction. + stageTimer = setTimeout(() => { + if (observeSettled) process.exit(3); + process.exit(77); + }, 700); + } else if (msg.id === 2) { + observeSettled = true; + console.log('HOST_STAGE observe_settled'); + } +}); +helper.stdin.write( + JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'initialize', params: {} }) + '\n', +); +setTimeout(() => { + // A blocked provider must still be unresolved when its parent disappears. + if (!initialized || !observeSent) process.exit(3); +}, 10000); diff --git a/experiments/maka-cu-windows/protocol-regression.mjs b/experiments/maka-cu-windows/protocol-regression.mjs new file mode 100644 index 0000000000..8b64a86d1c --- /dev/null +++ b/experiments/maka-cu-windows/protocol-regression.mjs @@ -0,0 +1,135 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +// Protocol-only regressions for the private Windows helper. These never +// launch a fixture or interact with a desktop window. +import { spawn } from 'node:child_process'; +import { createInterface } from 'node:readline'; + +const helperExe = process.argv[2]; +if (!helperExe) { + console.error('usage: node protocol-regression.mjs '); + process.exit(2); +} + +const waitExit = (child, timeoutMs) => + new Promise((resolve) => { + let done = false; + let timer; + const finish = (value) => { + if (!done) { + done = true; + resolve(value); + } + }; + child.once('exit', (code, signal) => { + clearTimeout(timer); + finish({ code, signal, timeout: false }); + }); + child.once('error', (error) => { + clearTimeout(timer); + finish({ code: null, signal: null, timeout: false, error: error.code ?? error.message }); + }); + timer = setTimeout(() => finish({ code: null, signal: null, timeout: true }), timeoutMs); + }); +const send = (child, value) => child.stdin.write(JSON.stringify(value) + '\n'); +const ensureStopped = async (child) => { + if (child.exitCode !== null) return; + child.kill(); + await waitExit(child, 1000); +}; + +async function malformedMethod() { + const child = spawn(helperExe, [], { stdio: ['pipe', 'pipe', 'ignore'] }); + const rl = createInterface({ input: child.stdout }); + const messages = []; + rl.on('line', (line) => { + try { + messages.push(JSON.parse(line)); + } catch {} + }); + send(child, { jsonrpc: '2.0', id: 1, method: 7 }); + const responseDeadline = Date.now() + 2000; + while (Date.now() < responseDeadline && !messages.some((m) => m.id === 1)) + await new Promise((r) => setTimeout(r, 20)); + child.stdin.end(); + const exit = await waitExit(child, 4000); + const pass = messages.some((m) => m.id === 1 && m.error?.code === -32600) && exit.code === 0; + console.log( + `${pass ? 'PASS' : 'FAIL'} malformed method is typed and helper exits`, + JSON.stringify({ messages, exit }), + ); + await ensureStopped(child); + return pass; +} + +async function unknownCancel() { + const child = spawn(helperExe, [], { stdio: ['pipe', 'pipe', 'ignore'] }); + const rl = createInterface({ input: child.stdout }); + const messages = []; + rl.on('line', (line) => { + try { + messages.push(JSON.parse(line)); + } catch {} + }); + send(child, { jsonrpc: '2.0', id: 1, method: 'debug_sleep', params: { ms: 300 } }); + send(child, { jsonrpc: '2.0', id: 2, method: 'debug_sleep', params: { ms: 20 } }); + send(child, { jsonrpc: '2.0', method: '$/cancel', params: { id: 999 } }); + send(child, { jsonrpc: '2.0', method: '$/cancel', params: [] }); + const until = Date.now() + 3000; + while ( + Date.now() < until && + (!messages.some((m) => m.id === 1) || !messages.some((m) => m.id === 2)) + ) + await new Promise((r) => setTimeout(r, 20)); + child.stdin.end(); + const exit = await waitExit(child, 4000); + const pass = + messages.some((m) => m.id === 1 && m.result?.sleptMs === 300) && + messages.some((m) => m.id === 2 && m.result?.sleptMs === 20) && + !messages.some((m) => m.id === null) && + exit.code === 0; + console.log( + `${pass ? 'PASS' : 'FAIL'} unknown cancel does not affect unrelated requests`, + JSON.stringify({ messages, exit }), + ); + await ensureStopped(child); + return pass; +} + +async function eofBackpressure() { + // Intentionally never attach a stdout reader. The bounded helper writer + // must fail closed and exit instead of becoming an orphan behind a pipe. + const child = spawn(helperExe, [], { stdio: ['pipe', 'pipe', 'ignore'] }); + for (let i = 0; i < 1000; i++) + send(child, { jsonrpc: '2.0', id: i + 1, method: 'initialize', params: {} }); + child.stdin.end(); + const exit = await waitExit(child, 5000); + const pass = exit.code === 0 || exit.code === 2; + console.log( + `${pass ? 'PASS' : 'FAIL'} EOF plus stdout backpressure exits bounded`, + JSON.stringify(exit), + ); + if (exit.timeout) child.kill(); + return pass; +} + +const results = [await malformedMethod(), await unknownCancel(), await eofBackpressure()]; +console.log(`protocol failures=${results.filter((x) => !x).length}`); +process.exitCode = results.every(Boolean) ? 0 : 1; diff --git a/experiments/maka-cu-windows/publish.ps1 b/experiments/maka-cu-windows/publish.ps1 new file mode 100644 index 0000000000..6fe3d7ee49 --- /dev/null +++ b/experiments/maka-cu-windows/publish.ps1 @@ -0,0 +1,69 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +param( + [string]$OutputRoot = (Join-Path $PSScriptRoot 'out'), + [switch]$NoBuild +) + +$ErrorActionPreference = 'Stop' +$helperProject = Join-Path $PSScriptRoot 'src/MakaCuWindows.csproj' +$fixtureProject = Join-Path $PSScriptRoot 'fixture/HangWindowFixture/HangWindowFixture.csproj' +$publishDir = Join-Path $OutputRoot 'publish' +$fixtureDir = Join-Path $OutputRoot 'fixture' + +New-Item -ItemType Directory -Force -Path $publishDir, $fixtureDir | Out-Null +if (-not $NoBuild) { + dotnet publish $helperProject -c Release -r win-x64 --self-contained true ` + -p:PublishSingleFile=true -p:PublishTrimmed=false -p:IncludeNativeLibrariesForSelfExtract=true ` + -o $publishDir + if ($LASTEXITCODE -ne 0) { throw "helper publish failed with exit code $LASTEXITCODE" } + dotnet publish $fixtureProject -c Release -r win-x64 --self-contained true ` + -p:PublishSingleFile=true -p:PublishTrimmed=false -p:IncludeNativeLibrariesForSelfExtract=true ` + -o $fixtureDir + if ($LASTEXITCODE -ne 0) { throw "fixture publish failed with exit code $LASTEXITCODE" } +} + +$helperExe = Join-Path $publishDir 'maka-cu-windows.exe' +$fixtureExe = Join-Path $fixtureDir 'maka-cu-windows-fixture.exe' +if (-not (Test-Path -LiteralPath $helperExe)) { throw "missing published helper: $helperExe" } +if (-not (Test-Path -LiteralPath $fixtureExe)) { throw "missing published fixture: $fixtureExe" } + +$sdk = (dotnet --version).Trim() +$files = @($helperExe, $fixtureExe) | ForEach-Object { + $item = Get-Item -LiteralPath $_ + $hash = (Get-FileHash -LiteralPath $_ -Algorithm SHA256).Hash + [ordered]@{ path = $item.FullName; bytes = $item.Length; sha256 = $hash } +} +$manifest = [ordered]@{ + protocol = 'maka.cu.windows/0' + targetFramework = 'net8.0-windows10.0.22621.0' + runtime = 'win-x64' + sdk = $sdk + selfContained = $true + singleFile = $true + trimmed = $false + nativeLibrariesForSelfExtract = $true + signature = 'none' + distributionReady = $false + files = $files + generatedUtc = [DateTime]::UtcNow.ToString('O') +} +$manifest | ConvertTo-Json -Depth 6 | Set-Content -LiteralPath (Join-Path $OutputRoot 'manifest.json') -Encoding utf8 +Write-Output "published helper: $helperExe" +Write-Output "published fixture: $fixtureExe" +Write-Output (Get-Content -LiteralPath (Join-Path $OutputRoot 'manifest.json') -Raw) diff --git a/experiments/maka-cu-windows/src/MakaCuWindows.csproj b/experiments/maka-cu-windows/src/MakaCuWindows.csproj new file mode 100644 index 0000000000..31a100d344 --- /dev/null +++ b/experiments/maka-cu-windows/src/MakaCuWindows.csproj @@ -0,0 +1,42 @@ + + + + + + Exe + + net8.0-windows10.0.22621.0 + MakaCuWindows + maka-cu-windows + enable + enable + false + + + + + + + + + \ No newline at end of file diff --git a/experiments/maka-cu-windows/src/Program.cs b/experiments/maka-cu-windows/src/Program.cs new file mode 100644 index 0000000000..0c9f117919 --- /dev/null +++ b/experiments/maka-cu-windows/src/Program.cs @@ -0,0 +1,988 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +// Feasibility spike v0 — supervised C#/.NET Windows helper for maka-cu +// (child of apache/maka#3785). Line-delimited JSON-RPC 2.0 on stdio. +// All UIA work runs on a dedicated MTA thread; the protocol loop lives on the +// main thread. stdin EOF (parent died / pipe closed) exits the process. +// +// Spike checks implemented in v0: +// 1. long-lived startup/handshake — initialize +// 2. one MTA UIA observation — observe (bounded tree) +// 3. one semantic action (ValuePattern) — act set_value / click_element; +// typed outcome + atomic snapshot spend BEFORE dispatch + pre/post +// revalidation (pid + startTime + windowGeneration) + readback +// 4. target-window WGC capture — capture (CreateForWindow) +// 5. cancellation with settlement — $/cancel (before/after dispatch) +// 6. recovery after hung provider — supervision lives host-side + +using System.Collections.Concurrent; +using System.Diagnostics; +using System.Runtime.InteropServices; +using System.Text; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Windows.Automation; + +const string PROTOCOL = "maka.cu.windows/0"; +const int MAX_TREE_NODES = 2000; +const int MAX_TREE_DEPTH = 32; +const int MAX_TREE_MILLIS = 2000; +const int MAX_TREE_RENDER_DEPTH = 4; // spike render: shallow skeleton only +const int SHUTDOWN_GRACE_MS = 1000; +const int MAX_RESPONSE_BYTES = 6 * 1024 * 1024; +const int CANCEL_GRACE_MS = 2000; +const int MAX_IN_FLIGHT = 32; +const int MAX_SNAPSHOTS = 64; +const uint COINIT_MULTITHREADED = 0; +const uint PROCESS_QUERY_LIMITED_INFORMATION = 0x1000; + +Console.OutputEncoding = new UTF8Encoding(false); + +var registry = new ConcurrentDictionary(); +var inbox = new BlockingCollection(MAX_IN_FLIGHT); +var active = new ConcurrentDictionary(); +var requests = new ConcurrentDictionary(); +var uiaThread = new Thread(() => UiaLoop(inbox, active, requests, registry)) { IsBackground = true, Name = "uia-mta" }; +uiaThread.Start(); + +while (Console.ReadLine() is { } line) +{ + if (string.IsNullOrWhiteSpace(line)) continue; + JsonObject? request; + try { request = JsonNode.Parse(line) as JsonObject; } + catch (JsonException) { WriteLine(ErrorRpc(null, -32700, "parse_error")); continue; } + if (request is null) + { WriteLine(ErrorRpc(null, -32600, "invalid_request")); continue; } + + string? method; + JsonNode? id; + JsonNode prms; + try + { + if (request["jsonrpc"] is not JsonValue rpc || !rpc.TryGetValue(out var rpcVersion) || rpcVersion != "2.0") + { WriteLine(ErrorRpc(null, -32600, "invalid_request")); continue; } + method = request["method"] is JsonValue methodValue && methodValue.TryGetValue(out var parsedMethod) ? parsedMethod : null; + id = request["id"]?.DeepClone(); + prms = request["params"] ?? new JsonObject(); + } + catch (Exception) { WriteLine(ErrorRpc(null, -32600, "invalid_request")); continue; } + if (method is null) { WriteLine(ErrorRpc(id, -32600, "invalid_request_method")); continue; } + if (method == "$/cancel") + { + var cancellation = MarkCancelled(prms, inbox, active, requests); + // A JSON-RPC notification has no response, including cancellation + // notifications. Request form remains useful to the driver. + if (id is not null) WriteLine(ResultRpc(id, cancellation)); + continue; + } + if (method == "shutdown") + { + WriteLine(ResultRpc(id, new JsonObject { ["ok"] = true })); + inbox.CompleteAdding(); + // Graceful shutdown is bounded. A provider that ignores cancellation + // cannot keep the helper alive indefinitely; the supervisor may also + // force-terminate it after the same 2-second grace. + await Task.Delay(SHUTDOWN_GRACE_MS); + Environment.Exit(0); + } + _ = HandleRequestAsync(method, id, prms, inbox, active, requests, registry); +} +// stdin EOF: the supervising host is gone (or piped input ended). No orphans. +Environment.Exit(0); + +static void WriteLine(string s) +{ + // Keep protocol input/control independent from a slow or abandoned + // stdout pipe. The bounded queue fails closed after a short admission + // bound; stdin EOF can therefore still reach the process exit path. + RpcOutput.Enqueue(s); +} + +static void WriteResponse(JsonNode? id, JsonObject? result, (int code, string message)? error) +{ + var line = error is { } e ? ErrorRpc(id, e.code, e.message) : ResultRpc(id, result); + if (Encoding.UTF8.GetByteCount(line) > MAX_RESPONSE_BYTES) + line = ErrorRpc(id, -32002, "response_too_large"); + WriteLine(line); +} + +static async Task HandleRequestAsync(string? method, JsonNode? id, JsonNode prms, + BlockingCollection inbox, ConcurrentDictionary active, + ConcurrentDictionary requests, + ConcurrentDictionary registry) +{ + try + { + if (method == "initialize") { WriteLine(ResultRpc(id, InitializeResult())); return; } + if (method is not ("list_windows" or "observe" or "act" or "capture" or "debug_sleep")) + { WriteLine(ErrorRpc(id, -32601, $"method_not_found: {method}")); return; } + var work = new UiaWork(method, prms) { Id = id?.DeepClone() }; + requests[work.Key] = work; + if (!inbox.TryAdd(work)) + { requests.TryRemove(work.Key, out _); WriteLine(ErrorRpc(id, -32000, "request_queue_full")); return; } + var (result, error) = await work.Tcs.Task; + WriteResponse(id, result, error is null ? null : (-32001, error)); + } + catch (InvalidOperationException) + { + WriteLine(ErrorRpc(id, -32000, "helper_shutting_down")); + } + catch (Exception ex) + { + WriteLine(ErrorRpc(id, -32603, $"internal_error: {ex.Message}")); + } +} + +static JsonObject MarkCancelled(JsonNode prms, BlockingCollection inbox, + ConcurrentDictionary active, ConcurrentDictionary requests) +{ + // A malformed cancellation must be a typed no-op. Indexing a JsonValue + // here would otherwise throw on the protocol reader thread. + if (prms is not JsonObject parameters) + return new JsonObject + { + ["cancelled"] = false, + ["reason"] = "invalid_cancel_params", + ["settlement"] = "original_request_must_settle", + ["graceMs"] = CANCEL_GRACE_MS, + }; + var requested = parameters["id"]?.DeepClone(); + UiaWork? target = null; + if (requested is not null) + target = requests.Values.FirstOrDefault(w => JsonEqual(w.Id, requested)); + else + target = active.Keys.FirstOrDefault() ?? inbox.FirstOrDefault(); + if (target is not null) target.RequestCancel(); + return new JsonObject + { + ["cancelled"] = target is not null, + ["pendingRequestId"] = target?.Id?.DeepClone(), + ["settlement"] = "original_request_must_settle", + ["graceMs"] = CANCEL_GRACE_MS, + }; +} + +static bool JsonEqual(JsonNode? a, JsonNode? b) => a is not null && b is not null && a.ToJsonString() == b.ToJsonString(); + +static JsonObject InitializeResult() => new() +{ + ["protocol"] = PROTOCOL, + ["executor"] = new JsonObject + { + ["name"] = "maka-cu-windows", + ["language"] = "csharp-dotnet8", + ["spikeStage"] = "v0", + }, + ["capabilities"] = new JsonObject + { + ["observation"] = new JsonObject { ["uia"] = true, ["wgc"] = true }, + ["semanticActions"] = new JsonArray("set_value", "click_element"), + ["input"] = new JsonObject + { + ["foreground"] = false, + ["globalPointer"] = false, + ["postMessage"] = false, + ["sendInput"] = false, + }, + ["capture"] = new JsonObject { ["targetWindowWgc"] = true, ["screenRect"] = false }, + }, + ["limits"] = new JsonObject + { + ["maxTreeNodes"] = MAX_TREE_NODES, + ["maxTreeDepth"] = MAX_TREE_DEPTH, + ["maxTreeMillis"] = MAX_TREE_MILLIS, + ["maxResponseBytes"] = MAX_RESPONSE_BYTES, + ["shutdownGraceMs"] = SHUTDOWN_GRACE_MS, + }, + ["deadlines"] = new JsonObject { ["handshake"] = 10, ["request"] = 20, ["cancelGrace"] = 2 }, + ["generation"] = RuntimeIdentity.HelperGeneration, + ["signature"] = "none", + ["distributionReady"] = false, + ["runtime"] = "net8.0-windows", +}; + +static string ErrorRpc(JsonNode? id, int code, string message) => + new JsonObject + { + ["jsonrpc"] = "2.0", + ["id"] = id?.DeepClone(), + ["error"] = new JsonObject { ["code"] = code, ["message"] = message }, + }.ToJsonString(); + +static string ResultRpc(JsonNode? id, JsonObject? result) => + new JsonObject + { + ["jsonrpc"] = "2.0", + ["id"] = id?.DeepClone(), + ["result"] = result ?? new JsonObject(), + }.ToJsonString(); + +// ---- UIA lane ------------------------------------------------------------- + +static void UiaLoop(BlockingCollection inbox, ConcurrentDictionary active, + ConcurrentDictionary requests, ConcurrentDictionary registry) +{ + _ = RpcInterop.CoInitializeEx(IntPtr.Zero, COINIT_MULTITHREADED); + foreach (var work in inbox.GetConsumingEnumerable()) + { + active[work] = 0; + try + { + // Cancellation settlement: an op cancelled while still queued + // must never dispatch (before-dispatch cancel ⇒ no mutation). + if (work.Cancelled && work.Op != "act") + { + work.Tcs.TrySetResult((null, "cancelled")); + continue; + } + if (work.Cancelled && work.Op == "act") + { + work.Tcs.TrySetResult((CancelQueuedAct(work, registry), null)); + continue; + } + if (work.Op != "act" && !work.TryBeginDispatch()) + { + work.Tcs.TrySetResult((null, "cancelled")); + continue; + } + (JsonObject? result, string? error) = work.Op switch + { + "list_windows" => ListWindows(), + "observe" => Observe(work.Params, registry), + "act" => Act(work.Params, work, registry), + "capture" => WgcCapture.Capture(work.Params), + "debug_sleep" => DebugSleep(work.Params), + _ => (null, $"unsupported_uia_op:{work.Op}"), + }; + work.Tcs.TrySetResult((result, error)); + } + catch (Exception ex) + { + work.Tcs.TrySetResult((null, $"uia_error: {ex.GetType().Name}: {ex.Message}")); + } + finally { active.TryRemove(work, out _); requests.TryRemove(work.Key, out _); } + } +} + +static JsonObject? CancelQueuedAct(UiaWork work, ConcurrentDictionary registry) +{ + var snapshotId = work.Params["snapshotId"]?.GetValue(); + if (snapshotId is not null) registry.TryRemove(snapshotId, out _); + return CancelledOutcome(); +} + +/// Typed outcome for a mutating op cancelled before dispatch: refused, no +/// mutation occurred, snapshot already spent. +static JsonObject? CancelledOutcome() => new() +{ + ["outcome"] = new JsonObject + { + ["tier"] = "cancelled-before-dispatch", + ["path"] = "none", + ["status"] = "refused", + ["reason"] = "cancelled_before_dispatch", + ["effect"] = "none", + ["snapshotSpent"] = true, + ["verification"] = "no_mutation", + }, +}; + +/// Spike-only test hook: blocks the UIA lane for N ms so queued ops can be +/// deterministically cancelled before dispatch. Bounded; never used by the +/// product path (check 6's blocked-provider fixture is a real UIA provider +/// call, not this). +static (JsonObject?, string?) DebugSleep(JsonNode prms) +{ + var ms = prms["ms"]?.GetValue() ?? 0; + if (ms < 0 || ms > 5000) return (null, "invalid_ms"); + Thread.Sleep(ms); + return (new JsonObject { ["sleptMs"] = ms }, null); +} + +static (JsonObject?, string?) ListWindows() +{ + var all = AutomationElement.RootElement.FindAll(TreeScope.Children, Condition.TrueCondition); + var arr = new JsonArray(); + long seen = 0; + foreach (AutomationElement el in all) + { + if (seen++ >= 64) break; + try + { + arr.Add(new JsonObject + { + ["hwnd"] = (long)el.Current.NativeWindowHandle, + ["pid"] = (long)el.Current.ProcessId, + ["title"] = Truncate(el.Current.Name ?? "", 256), + ["className"] = Truncate(el.Current.ClassName ?? "", 128), + ["isOffscreen"] = el.Current.IsOffscreen, + }); + } + catch (ElementNotAvailableException) { /* window vanished mid-enumeration */ } + } + return (new JsonObject { ["windows"] = arr }, null); +} + +static (JsonObject?, string?) Observe(JsonNode prms, ConcurrentDictionary registry) +{ + AutomationElement target; + long? requestedHwnd = prms["hwnd"] is null ? null : prms["hwnd"]!.GetValue(); + if (requestedHwnd is not long h || h <= 0) + return (null, "explicit_hwnd_required"); + target = AutomationElement.FromHandle(new IntPtr(h)); + if (target is null) return (null, "target_window_not_found"); + + var hwnd = (long)target.Current.NativeWindowHandle; + var pid = (uint)target.Current.ProcessId; + var startTime = ProcessStartTime(pid); + var windowGen = WgcCapture.WindowGeneration(target); + if (startTime is not long) + return (null, "target_process_start_time_unavailable"); + if (windowGen is not string) + return (null, "target_window_generation_unavailable"); + if (registry.Count >= MAX_SNAPSHOTS) + return (null, "snapshot_registry_full"); + + var snapshot = new SnapshotEntry(hwnd, pid, startTime.Value, windowGen, target); + var rootToken = snapshot.AddElement(target); + + var sw = Stopwatch.StartNew(); + var nodes = new JsonArray(); + var count = 1; + nodes.Add(BuildNode(target, rootToken)); + count = Walk(target, nodes, snapshot, count, 1, sw); + count = FindActionable(target, nodes, snapshot, count, sw); + + var id = $"snap-{Guid.NewGuid():N}"; + registry[id] = snapshot; + + var result = new JsonObject + { + ["snapshotId"] = id, + ["protocol"] = PROTOCOL, + ["target"] = new JsonObject + { + ["hwnd"] = hwnd, + ["pid"] = pid, + ["processStartTimeUtc"] = DateTime.FromFileTimeUtc(startTime.Value).ToString("O"), + ["title"] = Truncate(target.Current.Name ?? "", 256), + ["windowGeneration"] = windowGen, + }, + ["capture"] = new JsonObject + { + ["path"] = "capture_rpc", + ["status"] = "separate", + }, + ["tree"] = new JsonObject + { + ["rootToken"] = rootToken, + ["nodeCount"] = count, + ["truncated"] = count >= MAX_TREE_NODES || sw.ElapsedMilliseconds >= MAX_TREE_MILLIS, + ["elapsedMs"] = sw.ElapsedMilliseconds, + ["nodes"] = nodes, + }, + }; + // Response-size cap (check 2: bound text/response size): drop trailing + // nodes until the serialized tree fits, then flag truncated. + while (nodes.Count > 0 && Encoding.UTF8.GetByteCount(result.ToJsonString()) > MAX_RESPONSE_BYTES) + { + nodes.RemoveAt(nodes.Count - 1); + result["tree"]!["nodeCount"] = nodes.Count; + result["tree"]!["truncated"] = true; + } + return (result, null); +} + +/// Level-by-level cached walk over the top few levels only. Each node's +/// children are fetched with a children-scoped properties-only CacheRequest +/// (one small provider call per node, ms budget enforced between nodes). +/// Deliberately shallow: enumerating Chromium's full DOM subtree blocks for +/// minutes on individual FindAll calls, so actionable elements are found by +/// targeted FindFirst instead (see FindActionable). +static int Walk(AutomationElement parent, JsonArray nodes, SnapshotEntry snap, int count, int depth, Stopwatch sw) +{ + if (count >= MAX_TREE_NODES || depth >= MAX_TREE_RENDER_DEPTH || sw.ElapsedMilliseconds >= MAX_TREE_MILLIS) + return count; + + var request = new CacheRequest { TreeScope = TreeScope.Element }; + request.Add(AutomationElement.NameProperty); + request.Add(AutomationElement.AutomationIdProperty); + request.Add(AutomationElement.ClassNameProperty); + request.Add(AutomationElement.ControlTypeProperty); + request.Add(AutomationElement.IsEnabledProperty); + request.Add(AutomationElement.IsOffscreenProperty); + request.Add(AutomationElement.BoundingRectangleProperty); + request.Add(AutomationElement.NativeWindowHandleProperty); + request.Add(AutomationElement.ProcessIdProperty); + request.Add(AutomationElement.IsPasswordProperty); + + AutomationElementCollection children; + try + { + using (request.Activate()) + { + children = parent.FindAll(TreeScope.Children, Condition.TrueCondition); + } + } + catch (ElementNotAvailableException) { return count; } + + foreach (AutomationElement child in children) + { + if (count >= MAX_TREE_NODES || sw.ElapsedMilliseconds >= MAX_TREE_MILLIS) return count; + var token = snap.AddElement(child); + nodes.Add(BuildCachedNode(child, token)); + count++; + count = Walk(child, nodes, snap, count, depth + 1, sw); + } + return count; +} + +/// Targeted discovery of actionable elements: one FindFirst per supported +/// control type short-circuits at the first match, so the provider never +/// enumerates the full subtree. Found elements are LIVE (no cache request), +/// so pattern probing works on them. Cached render nodes cannot probe +/// patterns (patterns are not cached — that was prohibitively slow on +/// Chromium), so actionable candidates always come from this pass. +static int FindActionable(AutomationElement root, JsonArray nodes, SnapshotEntry snap, int count, Stopwatch sw) +{ + ControlType[] actionable = + { + ControlType.Edit, ControlType.ComboBox, ControlType.Button, ControlType.Hyperlink, + ControlType.CheckBox, ControlType.RadioButton, ControlType.ListItem, + ControlType.TabItem, ControlType.MenuItem, + }; + foreach (var ct in actionable) + { + if (count >= MAX_TREE_NODES || sw.ElapsedMilliseconds >= MAX_TREE_MILLIS) return count; + AutomationElement? found; + try + { + found = root.FindFirst(TreeScope.Descendants, new PropertyCondition(AutomationElement.ControlTypeProperty, ct)); + } + catch (ElementNotAvailableException) { continue; } + catch (System.Runtime.InteropServices.COMException) { continue; } + if (found is null) continue; + var token = snap.AddElement(found); + nodes.Add(BuildNode(found, token)); // live node: Current props + pattern probe + count++; + } + return count; +} + +static JsonObject BuildNode(AutomationElement el, string token) +{ + try + { + var bounds = el.Current.BoundingRectangle; + var patterns = ProbePatterns(el); + return new JsonObject + { + ["token"] = token, + ["controlType"] = el.Current.ControlType.ProgrammaticName, + ["name"] = Truncate(el.Current.Name ?? "", 256), + ["automationId"] = Truncate(el.Current.AutomationId ?? "", 128), + ["className"] = Truncate(el.Current.ClassName ?? "", 128), + ["isEnabled"] = el.Current.IsEnabled, + ["isOffscreen"] = el.Current.IsOffscreen, + ["bounds"] = new JsonArray(Fin(bounds.X), Fin(bounds.Y), Fin(bounds.Width), Fin(bounds.Height)), + ["patterns"] = patterns, + // Live value readback for Value-pattern nodes (used by the + // driver to verify mutations / absence of mutations). + ["value"] = patterns.Any(p => p?.GetValue() == "Value") ? ReadValueLive(el) : null, + }; + } + catch (ElementNotAvailableException) + { + return new JsonObject + { + ["token"] = token, + ["controlType"] = "(unavailable)", + ["name"] = "(element_died)", + ["isEnabled"] = false, + ["bounds"] = new JsonArray(0, 0, 0, 0), + ["patterns"] = new JsonArray(), + }; + } +} + +/// Reads the current value of a ValuePattern element (live nodes only; the +/// caller must have already established the Value pattern). +static string ReadValueLive(AutomationElement el) +{ + try + { + if (TryGetPattern(el, ValuePattern.Pattern, out var p, out var cached)) + { + var vp = (ValuePattern)p; + var v = Flavor(cached, () => vp.Cached.Value, () => vp.Current.Value); + return Truncate(v ?? "", 256); + } + } + catch (Exception) { /* element died or provider hiccup */ } + return ""; +} + +static JsonObject BuildCachedNode(AutomationElement el, string token) +{ + var bounds = el.Cached.BoundingRectangle; + return new JsonObject + { + ["token"] = token, + ["controlType"] = el.Cached.ControlType.ProgrammaticName, + ["name"] = Truncate(el.Cached.Name ?? "", 256), + ["automationId"] = Truncate(el.Cached.AutomationId ?? "", 128), + ["className"] = Truncate(el.Cached.ClassName ?? "", 128), + ["isEnabled"] = el.Cached.IsEnabled, + ["isOffscreen"] = el.Cached.IsOffscreen, + ["bounds"] = new JsonArray(Fin(bounds.X), Fin(bounds.Y), Fin(bounds.Width), Fin(bounds.Height)), + // Cached nodes carry no cached patterns (pattern caching is + // prohibitively slow on Chromium); actionable candidates come from + // the targeted FindActionable pass and are built with BuildNode. + ["patterns"] = new JsonArray(), + }; +} + +/// Pattern detection is the expensive part (live per-pattern provider calls). +/// Only probe types that plausibly carry a pattern; everything else returns [] +/// (a Chrome-sized tree is mostly Pane/Group/Document — this keeps RPCs ~0). +static JsonArray ProbePatterns(AutomationElement el) +{ + var patterns = new JsonArray(); + try + { + var t = GetControlType(el); + if (t == ControlType.Edit || t == ControlType.ComboBox) + { + if (TryGetPattern(el, ValuePattern.Pattern, out _, out _)) patterns.Add("Value"); + if (TryGetPattern(el, InvokePattern.Pattern, out _, out _)) patterns.Add("Invoke"); + } + else if (t == ControlType.Button || t == ControlType.Hyperlink || t == ControlType.MenuItem) + { + if (TryGetPattern(el, InvokePattern.Pattern, out _, out _)) patterns.Add("Invoke"); + } + else if (t == ControlType.CheckBox || t == ControlType.RadioButton) + { + if (TryGetPattern(el, TogglePattern.Pattern, out _, out _)) patterns.Add("Toggle"); + } + else if (t == ControlType.ListItem || t == ControlType.DataItem || t == ControlType.TabItem) + { + if (TryGetPattern(el, SelectionItemPattern.Pattern, out _, out _)) patterns.Add("SelectionItem"); + } + } + catch (ElementNotAvailableException) { /* treat as no patterns */ } + return patterns; +} + +static ControlType GetControlType(AutomationElement el) +{ + try { return el.Cached.ControlType; } + catch (InvalidOperationException) { return el.Current.ControlType; } + catch (ElementNotAvailableException) { return ControlType.Window; } +} + +/// Pattern retrieval that works for both cached elements (children walked +/// under a CacheRequest) and live elements (root from FromHandle). A cached +/// element throws InvalidOperationException on Current-pattern requests, and +/// a live element has no cached values; try the matching flavor first. +static bool TryGetPattern(AutomationElement el, AutomationPattern pattern, out object patternObj, out bool cached) +{ + patternObj = null!; + cached = false; + try + { + if (el.TryGetCachedPattern(pattern, out var p)) + { + patternObj = p; + cached = true; + return true; + } + } + catch (InvalidOperationException) { /* element has no cache (root) */ } + catch (ElementNotAvailableException) { return false; } + try + { + return el.TryGetCurrentPattern(pattern, out patternObj); + } + catch (InvalidOperationException) { return false; } // cached element, pattern not cached + catch (ElementNotAvailableException) { return false; } +} + +/// Reads a pattern property from whichever flavor the pattern came in, with +/// a cross-flavor fallback (cached patterns reject .Current and vice versa). +static T Flavor(bool cached, Func fromCache, Func fromCurrent) +{ + try { return cached ? fromCache() : fromCurrent(); } + catch (InvalidOperationException) { return cached ? fromCurrent() : fromCache(); } +} + +static bool? GetIsPassword(AutomationElement el) +{ + try { return el.Cached.IsPassword; } + catch (InvalidOperationException) { return el.Current.IsPassword; } + catch (ElementNotAvailableException) { return null; } + catch (COMException) { return null; } +} + +static (JsonObject?, string?) Act(JsonNode prms, UiaWork work, ConcurrentDictionary registry) +{ + if (prms is not JsonObject parameters) return (null, "invalid_params"); + var snapId = parameters["snapshotId"]?.GetValue(); + var token = parameters["elementToken"]?.GetValue(); + var op = parameters["op"]?.GetValue(); + if (snapId is null || token is null || op is null) return (null, "missing_required_param"); + if (op is not ("set_value" or "click_element")) return (null, $"unsupported_op:{op}"); + string? value = null; + if (op == "set_value") + { + if (parameters["value"] is not JsonValue valueNode || !valueNode.TryGetValue(out value)) + return (null, "invalid_value"); + } + var postDispatchDelay = parameters["debugPostDispatchDelayMs"]?.GetValue() ?? 0; + if (postDispatchDelay is < 0 or > 3000) return (null, "invalid_debugPostDispatchDelayMs"); + + if (!registry.TryGetValue(snapId, out var snap)) return (null, "snapshot_spent_or_unknown"); + + // Revalidate target identity before dispatch: HWND alive, owning PID + // unchanged, process incarnation unchanged, window generation unchanged. + // Fail closed. (Window generation is a fingerprint — see WgcCapture. + // When either value cannot be computed, the action is refused. + var hwnd = new IntPtr(snap.Hwnd); + var nowStart = ProcessStartTime(snap.Pid); + var nowGen = WgcCapture.WindowGeneration(hwnd); + var stale = !RpcInterop.IsWindow(hwnd) + || snap.Pid != OwningPid(hwnd) + || nowStart is not long || snap.StartTimeUtc != nowStart + || nowGen is not string || snap.WindowGen != nowGen + || snap.HelperGeneration != RuntimeIdentity.HelperGeneration; + if (stale) + { + registry.TryRemove(snapId, out _); + return (null, "stale_target_revalidate_failed"); + } + + if (!snap.TryGetElement(token, out var el)) return (null, "element_token_unknown_in_snapshot"); + + // Atomic snapshot spend BEFORE dispatch (check 3): the snapshot is + // consumed by this attempt regardless of dispatch outcome or cancellation. + registry.TryRemove(snapId, out _); + + (string status, string? reason, string verification) = op switch + { + // The CAS is deliberately inside the pattern helper, immediately + // before the provider mutator. Pattern lookup/readback may block. + "set_value" => SetValueVerified(el, value!, work.TryBeginDispatch), + "click_element" => ClickVerified(el, work.TryBeginDispatch), + _ => ("refused", $"unsupported_op:{op}", "none"), + }; + + // Fixture-only timing hook used to exercise cancellation after the + // provider has received the mutation. The value has already been + // delivered before this delay; cancellation therefore cannot claim that + // the action was undone. + if (postDispatchDelay > 0 && status != "refused") Thread.Sleep(postDispatchDelay); + + // Post-dispatch target revalidation (check 3): if the window/process + // incarnation changed while the action ran, the outcome is unknown. + if (status != "refused") + { + var postStart = ProcessStartTime(snap.Pid); + var postGen = WgcCapture.WindowGeneration(hwnd); + var postStale = !RpcInterop.IsWindow(hwnd) + || snap.Pid != OwningPid(hwnd) + || postStart is not long || snap.StartTimeUtc != postStart + || postGen is not string || snap.WindowGen != postGen; + if (postStale) + { + status = "unknown"; + reason = "target_died_during_action"; + verification = "post_revalidation_failed"; + } + } + + // (spend already happened atomically before dispatch) + + return (new JsonObject + { + ["outcome"] = new JsonObject + { + ["tier"] = "uia-pattern", + ["path"] = op == "set_value" ? "value_pattern" : op == "click_element" ? "invoke_toggle_selection" : "none", + ["status"] = status, + ["reason"] = reason, + ["effect"] = status == "verified" ? (op == "set_value" ? "value_set" : "invoked") + : status == "unknown" ? "possibly_dispatched" : "none", + ["snapshotSpent"] = true, + ["verification"] = verification, + } + }, null); +} + +static (string status, string? reason, string verification) SetValueVerified(AutomationElement el, string value, Func beginDispatch) +{ + if (!TryGetPattern(el, ValuePattern.Pattern, out var p, out var cached)) return ("refused", "value_pattern_unavailable", "none"); + var vp = (ValuePattern)p; + if (Flavor(cached, () => vp.Cached.IsReadOnly, () => vp.Current.IsReadOnly)) return ("refused", "value_pattern_readonly", "none"); + // .NET Core's UIAutomationClient has no ValuePatternInformation.IsPassword; + // read the element-level UIA_IsPasswordPropertyId instead. + var isPassword = GetIsPassword(el); + if (isPassword is null) return ("refused", "password_state_unavailable", "none"); + if (isPassword.Value) return ("refused", "password_field_refused", "none"); + if (!beginDispatch()) return ("refused", "cancelled_before_dispatch", "no_mutation"); + try { vp.SetValue(value); } + catch (ElementNotAvailableException) { return ("unknown", "element_died_during_dispatch", "readback_unavailable"); } + catch (InvalidOperationException) { return ("unknown", "element_not_available_after_dispatch", "readback_unavailable"); } + + // Post-dispatch readback: confirm the value actually landed (check 3). + try + { + var actual = Flavor(cached, () => vp.Cached.Value, () => vp.Current.Value); + if (actual == value) return ("verified", null, "value_readback_match"); + return ("unknown", $"value_readback_mismatch (got '{Truncate(actual, 60)}')", "value_readback_mismatch"); + } + catch (ElementNotAvailableException) { return ("unknown", "element_died_during_action", "readback_unavailable"); } + catch (InvalidOperationException) { return ("unknown", "readback_unavailable_after_dispatch", "readback_unavailable"); } +} + +static (string status, string? reason, string verification) ClickVerified(AutomationElement el, Func beginDispatch) +{ + // Prefer state-verifiable patterns (SelectionItem/Toggle) over Invoke so + // the outcome can be read back; Invoke has no state contract and may have + // side effects (e.g. a desktop icon Invoke opens the file). + if (TryGetPattern(el, SelectionItemPattern.Pattern, out var sp, out var sc)) + { + var sel = (SelectionItemPattern)sp; + if (!beginDispatch()) return ("refused", "cancelled_before_dispatch", "no_mutation"); + try { sel.Select(); } + catch (ElementNotAvailableException) { return ("unknown", "element_died_during_dispatch", "readback_unavailable"); } + catch (InvalidOperationException) { return ("unknown", "select_failed_after_dispatch", "readback_unavailable"); } + try + { + var selected = Flavor(sc, () => sel.Cached.IsSelected, () => sel.Current.IsSelected); + if (selected) return ("verified", null, "selection_readback_selected"); + return ("unknown", "selection_not_selected_after_action", "readback_mismatch"); + } + catch (Exception) { return ("unknown", "selection_readback_unavailable", "readback_unavailable"); } + } + if (TryGetPattern(el, TogglePattern.Pattern, out var tp, out var tc)) + { + var tgl = (TogglePattern)tp; + ToggleState before; + try { before = Flavor(tc, () => tgl.Cached.ToggleState, () => tgl.Current.ToggleState); } + catch (Exception) { return ("unknown", "toggle_prestate_unreadable", "readback_unavailable"); } + if (!beginDispatch()) return ("refused", "cancelled_before_dispatch", "no_mutation"); + try { tgl.Toggle(); } + catch (ElementNotAvailableException) { return ("unknown", "element_died_during_toggle", "readback_unavailable"); } + catch (InvalidOperationException) { return ("unknown", "toggle_failed", "readback_unavailable"); } + try + { + var after = Flavor(tc, () => tgl.Cached.ToggleState, () => tgl.Current.ToggleState); + if (after != before) return ("verified", null, "toggle_state_readback_changed"); + return ("unknown", "toggle_state_unchanged_after_action", "readback_mismatch"); + } + catch (Exception) { return ("unknown", "toggle_readback_unavailable", "readback_unavailable"); } + } + if (TryGetPattern(el, InvokePattern.Pattern, out var ip, out _)) + { + if (!beginDispatch()) return ("refused", "cancelled_before_dispatch", "no_mutation"); + try { ((InvokePattern)ip).Invoke(); } + catch (ElementNotAvailableException) { return ("unknown", "element_died_during_dispatch", "readback_unavailable"); } + catch (InvalidOperationException) { return ("unknown", "invoke_failed_after_dispatch", "readback_unavailable"); } + // Invoke has no observable state contract; dispatch success is the + // verification level for this path. + return ("verified", null, "invoke_dispatched_no_state_readback"); + } + return ("refused", "no_invoke_toggle_selection_pattern", "none"); +} + +static string Truncate(string s, int max) => s.Length <= max ? s : s[..max]; + +/// Bounding rectangles of offscreen elements can be +/-Infinity, which +/// System.Text.Json refuses to serialize; map non-finite floats to 0. +static double Fin(double v) => double.IsFinite(v) ? v : 0d; + +static uint OwningPid(IntPtr hwnd) +{ + RpcInterop.GetWindowThreadProcessId(hwnd, out var pid); + return pid; +} + +static long? ProcessStartTime(uint pid) +{ + var h = RpcInterop.OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, false, pid); + if (h == IntPtr.Zero) return null; // UIPI/elevation: no query right; revalidation degrades to HWND+PID + try + { + return RpcInterop.GetProcessTimes(h, out var creation, out _, out _, out _) ? creation : null; + } + finally { _ = RpcInterop.CloseHandle(h); } +} + +sealed class UiaWork +{ + // 0=pending/prevalidation, 1=provider dispatch has begun, 2=cancelled + // before dispatch. The CAS is the serialization point between cancel and + // a mutating UIA call. + private int _dispatchState; + public string Op { get; } + public JsonNode Params { get; } + public JsonNode? Id { get; init; } + public string Key { get; } = Guid.NewGuid().ToString("N"); + public volatile bool Cancelled; + public TaskCompletionSource<(JsonObject? result, string? error)> Tcs { get; } = new(); + + public UiaWork(string op, JsonNode prms) + { + Op = op; + Params = prms; + } + + public void RequestCancel() + { + Cancelled = true; + _ = Interlocked.CompareExchange(ref _dispatchState, 2, 0); + } + + public bool TryBeginDispatch() => + Interlocked.CompareExchange(ref _dispatchState, 1, 0) == 0; +} + +/// Snapshot registry entry. Element tokens are opaque and resolve only here; +/// the AutomationElement COM proxies live on the UIA lane thread. +sealed class SnapshotEntry +{ + private readonly List<(string token, AutomationElement el, int[]? runtimeId)> _elements = new(); + public long Hwnd { get; } + public uint Pid { get; } + public long StartTimeUtc { get; } + public string WindowGen { get; } + public string HelperGeneration { get; } + public AutomationElement Root { get; } + + public SnapshotEntry(long hwnd, uint pid, long startTimeUtc, string windowGen, AutomationElement root) + { + Hwnd = hwnd; + Pid = pid; + StartTimeUtc = startTimeUtc; + WindowGen = windowGen; + HelperGeneration = RuntimeIdentity.HelperGeneration; + Root = root; + } + + public string AddElement(AutomationElement el) + { + if (_elements.Count >= 2000) + throw new InvalidOperationException("snapshot_element_limit"); + // Tokens must not be reusable across snapshots: a caller mixing an + // old token with a new snapshot must fail closed. + var token = $"el-{Guid.NewGuid():N}"; + _elements.Add((token, el, RuntimeId(el))); + return token; + } + + public bool TryGetElement(string token, out AutomationElement el) + { + foreach (var (t, e, savedRuntimeId) in _elements) + { + if (t == token) + { + // RuntimeId is stable for the retained provider element. If + // it was unavailable at either point, refuse rather than + // silently treating a recycled provider proxy as identical. + var currentRuntimeId = RuntimeId(e); + if (savedRuntimeId is null || currentRuntimeId is null || !savedRuntimeId.SequenceEqual(currentRuntimeId)) + { + el = null!; + return false; + } + el = e; + return true; + } + } + el = null!; + return false; + } + + static int[]? RuntimeId(AutomationElement el) + { + try { return el.GetRuntimeId(); } + catch (ElementNotAvailableException) { return null; } + catch (COMException) { return null; } + catch (InvalidOperationException) { return null; } + } +} + +static class RpcInterop +{ + [DllImport("ole32.dll")] + public static extern int CoInitializeEx(IntPtr pvReserved, uint dwCoInit); + + [DllImport("user32.dll")] + [return: MarshalAs(UnmanagedType.Bool)] + public static extern bool IsWindow(IntPtr hWnd); + + [DllImport("user32.dll")] + public static extern uint GetWindowThreadProcessId(IntPtr hWnd, out uint processId); + + [DllImport("kernel32.dll", SetLastError = true)] + public static extern IntPtr OpenProcess(uint dwDesiredAccess, [MarshalAs(UnmanagedType.Bool)] bool bInheritHandle, uint dwProcessId); + + [DllImport("kernel32.dll")] + [return: MarshalAs(UnmanagedType.Bool)] + public static extern bool GetProcessTimes(IntPtr hProcess, out long lpCreationTime, out long lpExitTime, out long lpKernelTime, out long lpUserTime); + + [DllImport("kernel32.dll")] + [return: MarshalAs(UnmanagedType.Bool)] + public static extern bool CloseHandle(IntPtr hObject); +} + +static class RuntimeIdentity +{ + // A fresh helper process owns a unique namespace for snapshots/tokens. + // The process id is retained for readable diagnostics but is not the + // generation value because Windows may reuse a pid. + public static readonly string HelperGeneration = $"{Environment.ProcessId}-{Guid.NewGuid():N}"; +} + +static class RpcOutput +{ + static readonly BlockingCollection Queue = new(64); + static RpcOutput() + { + var writer = new Thread(() => + { + foreach (var line in Queue.GetConsumingEnumerable()) + { + try + { + Console.Out.WriteLine(line); + Console.Out.Flush(); + } + catch { Environment.Exit(2); } + } + }) { IsBackground = true, Name = "rpc-stdout-writer" }; + writer.Start(); + } + + public static void Enqueue(string line) + { + if (!Queue.TryAdd(line, millisecondsTimeout: 100)) + Environment.Exit(2); + } +} diff --git a/experiments/maka-cu-windows/src/WgcCapture.cs b/experiments/maka-cu-windows/src/WgcCapture.cs new file mode 100644 index 0000000000..69dfb4524c --- /dev/null +++ b/experiments/maka-cu-windows/src/WgcCapture.cs @@ -0,0 +1,421 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +// WGC capture interop for the spike helper (check 4): target-window capture +// via IGraphicsCaptureItemInterop::CreateForWindow(HWND), one frame acquired +// through a Direct3D11 frame pool, pixels copied through a D3D11 staging +// texture and encoded as PNG so the occlusion test has real bytes to compare. +// No screen-rectangle fallback anywhere: any failure reports +// capture_unavailable with a typed reason. +// +// Also hosts the window-generation fingerprint (check 2 identity), which +// shares the RuntimeId / window-property machinery. + +using System.Diagnostics; +using System.IO.Compression; +using System.Runtime.InteropServices; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json.Nodes; +using System.Windows.Automation; +using Windows.Graphics.Capture; +using Windows.Graphics.DirectX; +using Windows.Graphics.DirectX.Direct3D11; + +static class WgcCapture +{ + const uint D3D11_SDK_VERSION = 7; + const uint D3D_DRIVER_TYPE_HARDWARE = 1; + const uint D3D11_CREATE_DEVICE_BGRA_SUPPORT = 0x20; + const uint D3D11_USAGE_STAGING = 3; + const uint D3D11_CPU_ACCESS_READ = 0x20000; + const uint D3D11_MAP_READ = 1; + const long MAX_CAPTURE_PIXELS = 16_000_000; + const int MAX_PNG_BYTES = 4 * 1024 * 1024; + static readonly uint[] FeatureLevels = { 0xb100, 0xb000, 0xa100, 0xa000, 0x9300 }; // 11_1..9_3 + // Not readonly: passed by ref to QueryInterface/CreateForWindow (CS0199). + static Guid IID_IDXGIDevice = new("54ec77fa-1377-44e6-8c32-88fd5f44c84c"); + static Guid IID_ID3D11Texture2D = new("6f15aaf2-d208-4e89-9ab4-489535d34f9c"); + static Guid IID_IDirect3DDxgiInterfaceAccess = new("a9b3d012-3df2-4ee3-b8d1-8695f457d3c1"); + static Guid IID_IGraphicsCaptureItemInterop = new("3628e81b-3cac-4c60-b7f4-23ce0e0c3356"); + // IGraphicsCaptureItem default interface from the Windows SDK ABI. + static Guid IID_IGraphicsCaptureItem = new("79c3f95b-31f7-4ec2-a464-632ef5d30760"); + const int FRAME_TIMEOUT_MS = 3000; + + [DllImport("d3d11.dll")] + static extern int D3D11CreateDevice(IntPtr adapter, uint driverType, IntPtr software, uint flags, + [In, MarshalAs(UnmanagedType.LPArray)] uint[] featureLevels, uint numLevels, uint sdkVersion, + out IntPtr device, out uint featureLevel, out IntPtr immediateContext); + + [DllImport("d3d11.dll")] + static extern int CreateDirect3D11DeviceFromDXGIDevice(IntPtr dxgiDevice, out IntPtr graphicsDevice); + + [DllImport("combase.dll")] + static extern int RoGetActivationFactory(IntPtr activatableClassId, ref Guid riid, out IntPtr factory); + [DllImport("combase.dll", CharSet = CharSet.Unicode)] + static extern int WindowsCreateString(string sourceString, uint length, out IntPtr hstring); + [DllImport("combase.dll")] + static extern int WindowsDeleteString(IntPtr hstring); + + [ComImport, Guid("3628e81b-3cac-4c60-b7f4-23ce0e0c3356"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] + interface IGraphicsCaptureItemInterop + { + [PreserveSig] int CreateForWindow(IntPtr hwnd, ref Guid riid, out IntPtr item); + [PreserveSig] int CreateForMonitor(IntPtr hmonitor, ref Guid riid, out IntPtr item); + } + + // ---- capture RPC (check 4) ------------------------------------------- + + public static (JsonObject?, string?) Capture(JsonNode prms) + { + Trace("enter"); + long? h = prms["hwnd"]?.GetValue(); + if (h is not long hwnd || hwnd <= 0) return (null, "missing_hwnd"); + if (!RpcInterop.IsWindow(new IntPtr(hwnd))) return (Unavailable("target_window_gone"), null); + var expectedGen = prms["windowGeneration"]?.GetValue(); + var nowGen = WindowGeneration(new IntPtr(hwnd)); + if (expectedGen is null || nowGen is null || expectedGen != nowGen) + return (Unavailable(nowGen is null ? "target_window_generation_unavailable" : "stale_target_window_generation"), null); + + // 1. native D3D11 device + immediate context + int hr = D3D11CreateDevice(IntPtr.Zero, D3D_DRIVER_TYPE_HARDWARE, IntPtr.Zero, D3D11_CREATE_DEVICE_BGRA_SUPPORT, + FeatureLevels, (uint)FeatureLevels.Length, D3D11_SDK_VERSION, out var devicePtr, out _, out var ctxPtr); + Trace($"d3d11_create hr=0x{hr:X8} device=0x{devicePtr.ToInt64():X} context=0x{ctxPtr.ToInt64():X}"); + if (hr != 0 || devicePtr == IntPtr.Zero) + return (Unavailable($"d3d11_device_create_failed hr=0x{hr:X8}"), null); + try + { + // 2. IDXGIDevice -> WinRT IDirect3DDevice (documented interop) + hr = Marshal.QueryInterface(devicePtr, ref IID_IDXGIDevice, out var dxgiPtr); + Trace($"qi_dxgi hr=0x{hr:X8} ptr=0x{dxgiPtr.ToInt64():X}"); + if (hr != 0) return (Unavailable($"dxgi_device_qi_failed hr=0x{hr:X8}"), null); + try + { + hr = CreateDirect3D11DeviceFromDXGIDevice(dxgiPtr, out var winrtDevicePtr); + Trace($"winrt_device hr=0x{hr:X8} ptr=0x{winrtDevicePtr.ToInt64():X}"); + if (hr != 0 || winrtDevicePtr == IntPtr.Zero) + return (Unavailable($"winrt_device_create_failed hr=0x{hr:X8}"), null); + var d3dDevice = WinRT.MarshalInterface.FromAbi(winrtDevicePtr); + // FromAbi returns a CsWinRT projected object. Release the + // ABI reference once; cleanup of the projected object is via + // IDisposable/IClosable, not Marshal.ReleaseComObject. + Marshal.Release(winrtDevicePtr); + try + { + // 3. GraphicsCaptureItem via the class object's interop interface + var item = CreateItemForWindow(new IntPtr(hwnd), out var itemDiag); + Trace($"create_item item={(item is null ? "null" : "ok")} diag={itemDiag}"); + if (item is null) return (Unavailable($"createforwindow_failed ({itemDiag})"), null); + + using var pool = Direct3D11CaptureFramePool.CreateFreeThreaded( + d3dDevice, DirectXPixelFormat.B8G8R8A8UIntNormalized, 2, item.Size); + using var session = pool.CreateCaptureSession(item); + session.StartCapture(); + Trace("capture_started"); + + // 4. first frame, bounded wait + var sw = Stopwatch.StartNew(); + Direct3D11CaptureFrame? frame = null; + while (sw.ElapsedMilliseconds < FRAME_TIMEOUT_MS && frame is null) + { + frame = pool.TryGetNextFrame(); + if (frame is null) Thread.Sleep(40); + } + if (frame is null) return (Unavailable($"no_frame_within_{FRAME_TIMEOUT_MS}ms"), null); + Trace("frame_acquired"); + + using (frame) + { + var pixelCount = (long)item.Size.Width * item.Size.Height; + if (item.Size.Width <= 0 || item.Size.Height <= 0 || pixelCount > MAX_CAPTURE_PIXELS) + return (Unavailable($"capture_dimensions_exceed_limit {item.Size.Width}x{item.Size.Height}"), null); + // 5. copy the GPU texture to a CPU-readable staging + // texture and return a real PNG payload. There is no + // screen-rectangle fallback. + var (png, reason) = ReadPixels(devicePtr, ctxPtr, frame, item.Size.Width, item.Size.Height); + Trace($"read_pixels png={(png is null ? "null" : png.Length.ToString())} reason={reason}"); + if (png is null) return (Unavailable(reason!), null); + if (png.Length > MAX_PNG_BYTES) + return (Unavailable($"png_exceeds_limit bytes={png.Length}"), null); + var hash = Convert.ToHexString(SHA256.HashData(png)); + return (new JsonObject + { + ["status"] = "available", + ["path"] = "wgc_createforwindow", + ["frame"] = new JsonObject + { + ["width"] = item.Size.Width, + ["height"] = item.Size.Height, + ["bytes"] = png.Length, + ["format"] = "png", + ["base64"] = Convert.ToBase64String(png), + ["sha256"] = hash, + ["elapsedMs"] = sw.ElapsedMilliseconds, + }, + }, null); + } + } + finally { d3dDevice.Dispose(); } + } + finally { Marshal.Release(dxgiPtr); } + } + finally { if (devicePtr != IntPtr.Zero) Marshal.Release(devicePtr); if (ctxPtr != IntPtr.Zero) Marshal.Release(ctxPtr); } + } + + static JsonObject Unavailable(string reason) => new() + { + ["status"] = "unavailable", + ["path"] = "none", + ["reason"] = reason, + }; + + static GraphicsCaptureItem? CreateItemForWindow(IntPtr hwnd, out string? diag) + { + diag = null; + var sb = new StringBuilder(); + // RoGetActivationFactory expects an HSTRING class id, not a raw + // wide-char buffer (StringToHGlobalUni -> E_INVALIDARG). + var className = "Windows.Graphics.Capture.GraphicsCaptureItem"; + var createHr = WindowsCreateString(className, (uint)className.Length, out var classId); + if (createHr != 0 || classId == IntPtr.Zero) + { diag = $"hstring_create=0x{createHr:X8}"; return null; } + try + { + int hr = RoGetActivationFactory(classId, ref IID_IGraphicsCaptureItemInterop, out var factoryPtr); + sb.Append($"roget=0x{hr:X8}"); + if (hr != 0 || factoryPtr == IntPtr.Zero) { diag = sb.ToString(); return null; } + var interop = (IGraphicsCaptureItemInterop)Marshal.GetObjectForIUnknown(factoryPtr); + try + { + sb.Append($" | {IID_IGraphicsCaptureItem:D}:0x"); + hr = interop.CreateForWindow(hwnd, ref IID_IGraphicsCaptureItem, out var itemPtr); + sb.Append($"{hr:X8}"); + Trace($"create_for_window hr=0x{hr:X8} ptr=0x{itemPtr.ToInt64():X}"); + if (hr == 0 && itemPtr != IntPtr.Zero) + { + try + { + var item = GraphicsCaptureItem.FromAbi(itemPtr); + if (item is not null) { diag = sb.ToString(); return item; } + } + finally { Marshal.Release(itemPtr); } + } + } + finally { Marshal.ReleaseComObject(interop); } + } + finally { WindowsDeleteString(classId); } + diag = sb.ToString(); + return null; + } + + // ---- pixel readback via D3D11 staging texture -------------------------- + + [StructLayout(LayoutKind.Sequential)] + struct D3D11Texture2DDesc + { + public int Width, Height, MipLevels, ArraySize; + public int Format; + public int SampleCount, SampleQuality; + public uint Usage, BindFlags, CpuAccessFlags, MiscFlags; + } + + [StructLayout(LayoutKind.Sequential)] + struct D3D11MappedSubresource + { + public IntPtr Data; + public int RowPitch, DepthPitch; + } + + [UnmanagedFunctionPointer(CallingConvention.StdCall)] + delegate int CreateTexture2DDelegate(IntPtr self, ref D3D11Texture2DDesc desc, IntPtr initialData, out IntPtr texture); + [UnmanagedFunctionPointer(CallingConvention.StdCall)] + delegate void CopyResourceDelegate(IntPtr self, IntPtr destination, IntPtr source); + [UnmanagedFunctionPointer(CallingConvention.StdCall)] + delegate int MapDelegate(IntPtr self, IntPtr resource, uint subresource, uint mapType, uint mapFlags, out D3D11MappedSubresource mapped); + [UnmanagedFunctionPointer(CallingConvention.StdCall)] + delegate void UnmapDelegate(IntPtr self, IntPtr resource, uint subresource); + [UnmanagedFunctionPointer(CallingConvention.StdCall)] + delegate int GetInterfaceDelegate(IntPtr self, ref Guid iid, out IntPtr result); + + static T ComMethod(IntPtr comObject, int slot) where T : Delegate + { + var vtable = Marshal.ReadIntPtr(comObject); + var address = Marshal.ReadIntPtr(vtable, slot * IntPtr.Size); + return Marshal.GetDelegateForFunctionPointer(address); + } + + static (byte[]? bytes, string? reason) ReadPixels(IntPtr device, IntPtr context, + Direct3D11CaptureFrame frame, int width, int height) + { + IntPtr sourceTexture = IntPtr.Zero; + WinRT.IObjectReference? accessRef = null; + IntPtr stagingTexture = IntPtr.Zero; + try + { + // Get the native ABI pointer from the CsWinRT projection. Calling + // Marshal.GetIUnknownForObject here can create a CCW rather than + // expose the underlying IDirect3DSurface object. + if (frame.Surface is not WinRT.IWinRTObject surfaceObject + || surfaceObject.NativeObject is not WinRT.IObjectReference surfaceRef) + return (null, "surface_native_object_unavailable"); + Trace($"surface_native ptr=0x{surfaceRef.ThisPtr.ToInt64():X}"); + accessRef = surfaceRef.As(IID_IDirect3DDxgiInterfaceAccess); + var access = accessRef.ThisPtr; + var hr = ComMethod(access, 3)(access, ref IID_ID3D11Texture2D, out sourceTexture); + if (hr != 0 || sourceTexture == IntPtr.Zero) + return (null, $"frame_surface_qi_texture_failed hr=0x{hr:X8}"); + var desc = new D3D11Texture2DDesc + { + Width = width, Height = height, MipLevels = 1, ArraySize = 1, + // DXGI_FORMAT_B8G8R8A8_UNORM, the capture-pool format above. + Format = 87, SampleCount = 1, SampleQuality = 0, + Usage = D3D11_USAGE_STAGING, BindFlags = 0, + CpuAccessFlags = D3D11_CPU_ACCESS_READ, MiscFlags = 0, + }; + hr = ComMethod(device, 5)(device, ref desc, IntPtr.Zero, out stagingTexture); + if (hr != 0 || stagingTexture == IntPtr.Zero) + return (null, $"staging_texture_create_failed hr=0x{hr:X8}"); + ComMethod(context, 47)(context, stagingTexture, sourceTexture); + hr = ComMethod(context, 14)(context, stagingTexture, 0, D3D11_MAP_READ, 0, out var mapped); + if (hr != 0 || mapped.Data == IntPtr.Zero) + return (null, $"staging_texture_map_failed hr=0x{hr:X8}"); + try + { + var bgra = new byte[checked(width * height * 4)]; + for (var y = 0; y < height; y++) + Marshal.Copy(IntPtr.Add(mapped.Data, y * mapped.RowPitch), bgra, y * width * 4, width * 4); + return (EncodePngBgra(bgra, width, height), null); + } + finally { ComMethod(context, 15)(context, stagingTexture, 0); } + } + finally + { + if (stagingTexture != IntPtr.Zero) Marshal.Release(stagingTexture); + if (sourceTexture != IntPtr.Zero) Marshal.Release(sourceTexture); + accessRef?.Dispose(); + } + } + + static byte[] EncodePngBgra(byte[] bgra, int width, int height) + { + using var raw = new MemoryStream(); + for (var y = 0; y < height; y++) + { + raw.WriteByte(0); // PNG filter: None + for (var x = 0; x < width; x++) + { + var i = (y * width + x) * 4; + raw.WriteByte(bgra[i + 2]); raw.WriteByte(bgra[i + 1]); + raw.WriteByte(bgra[i]); raw.WriteByte(bgra[i + 3]); + } + } + using var compressed = new MemoryStream(); + using (var z = new ZLibStream(compressed, CompressionLevel.Fastest, leaveOpen: true)) + { + raw.Position = 0; + raw.CopyTo(z); + } + using var png = new MemoryStream(); + png.Write(new byte[] { 137, 80, 78, 71, 13, 10, 26, 10 }); + WriteChunk(png, "IHDR", Header(width, height)); + WriteChunk(png, "IDAT", compressed.ToArray()); + WriteChunk(png, "IEND", Array.Empty()); + return png.ToArray(); + } + + static byte[] Header(int width, int height) + { + var b = new byte[13]; + WriteBigEndian(b, 0, width); WriteBigEndian(b, 4, height); + b[8] = 8; b[9] = 6; // RGBA, 8 bits/channel + return b; + } + + static void WriteChunk(Stream output, string type, byte[] data) + { + var typeBytes = Encoding.ASCII.GetBytes(type); + WriteBigEndian(output, data.Length); output.Write(typeBytes); + output.Write(data); + var crc = 0xffffffffu; + foreach (var b in typeBytes.Concat(data)) + { + crc ^= b; + for (var i = 0; i < 8; i++) crc = (crc >> 1) ^ (0xedb88320u & (uint)-(int)(crc & 1)); + } + WriteBigEndian(output, ~crc); + } + + static void WriteBigEndian(Stream output, int value) => WriteBigEndian(output, unchecked((uint)value)); + static void WriteBigEndian(Stream output, uint value) + { + output.WriteByte((byte)(value >> 24)); output.WriteByte((byte)(value >> 16)); + output.WriteByte((byte)(value >> 8)); output.WriteByte((byte)value); + } + static void WriteBigEndian(byte[] output, int offset, int value) + { + output[offset] = (byte)(value >> 24); output[offset + 1] = (byte)(value >> 16); + output[offset + 2] = (byte)(value >> 8); output[offset + 3] = (byte)value; + } + + static void Trace(string message) + { + try { Console.Error.WriteLine($"WGC {DateTime.UtcNow:O} {message}"); Console.Error.Flush(); } + catch { /* diagnostics must never affect protocol */ } + } + + // ---- window generation fingerprint (check 2) -------------------------- + + /// Fingerprint of the current window instance: hwnd + class + title + + /// bounds + pid + thread + UIA RuntimeId, hashed. Windows exposes no + /// per-window-instance token; a recreated window normally gets a new HWND + /// (caught by IsWindow / this hash). True HWND-value reuse with identical + /// properties is a documented residual limitation (decision record D4). + /// Null when the fingerprint cannot be computed — callers then degrade to + /// HWND+PID+startTime checks only. + public static string? WindowGeneration(IntPtr hwnd) + { + try + { + var el = AutomationElement.FromHandle(hwnd); + return el is null ? null : WindowGeneration(el); + } + catch (Exception) { return null; } + } + + public static string? WindowGeneration(AutomationElement root) + { + try + { + var sb = new StringBuilder(); + var w = (long)root.Current.NativeWindowHandle; + sb.Append(w).Append('|'); + sb.Append(root.Current.ClassName).Append('|'); + sb.Append(root.Current.Name).Append('|'); + var r = root.Current.BoundingRectangle; + sb.Append(r.X).Append(',').Append(r.Y).Append(',').Append(r.Width).Append(',').Append(r.Height).Append('|'); + sb.Append((long)root.Current.ProcessId).Append('|'); + _ = RpcInterop.GetWindowThreadProcessId(new IntPtr(w), out var tid); + sb.Append((long)tid).Append('|'); + try { foreach (var rid in root.GetRuntimeId()) sb.Append(rid).Append(';'); } catch { /* provider-dependent */ } + using var sha = SHA256.Create(); + return Convert.ToHexString(sha.ComputeHash(Encoding.UTF8.GetBytes(sb.ToString())))[..16]; + } + catch (Exception) { return null; } + } +} diff --git a/scripts/asf-license-headers.mjs b/scripts/asf-license-headers.mjs index 1e035f61e9..13c4af3a56 100644 --- a/scripts/asf-license-headers.mjs +++ b/scripts/asf-license-headers.mjs @@ -107,6 +107,8 @@ const commentStyles = { */ const coveredExtensions = new Map([ ['.cjs', 'block'], + ['.cs', 'block'], + ['.csproj', 'html'], ['.css', 'block'], ['.html', 'html'], ['.js', 'block'], diff --git a/scripts/asf-license-headers.test.mjs b/scripts/asf-license-headers.test.mjs index 20a2fc6b8b..7a38e43c5a 100644 --- a/scripts/asf-license-headers.test.mjs +++ b/scripts/asf-license-headers.test.mjs @@ -211,6 +211,8 @@ describe('ASF header classification', () => { 'scripts/asf-source-release.mjs', 'apps/desktop/build/installer.nsh', 'experiments/windows-sandbox/launcher/src/main.rs', + 'experiments/maka-cu-windows/src/Program.cs', + 'experiments/maka-cu-windows/src/MakaCuWindows.csproj', 'packages/eval/harbor/egress-proxy/Dockerfile', 'packages/eval/harbor/egress-proxy/network-policy', '.github/workflows/ci.yml',