From a21c09b8d7ab096dd8195dfae967071bbd9a190a Mon Sep 17 00:00:00 2001 From: yusei Date: Sat, 17 Oct 2026 22:45:44 +0900 Subject: [PATCH] =?UTF-8?q?fix(computer-use):=20#823=20Windows=20=E3=81=AE?= =?UTF-8?q?=20V1=20=E3=81=AE=20UI=20Automation=20=E3=81=A7=E3=80=81?= =?UTF-8?q?=E5=80=A4=E3=82=92=E8=BF=94=E3=81=97=E3=81=9F=E9=81=85=E3=81=84?= =?UTF-8?q?=E6=88=90=E5=8A=9F=E3=82=92=E6=99=82=E9=96=93=E5=88=87=E3=82=8C?= =?UTF-8?q?=E3=81=AB=E3=81=97=E3=81=AA=E3=81=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #820(PR #821)の規則 4 は、270 ms(最初の接触は 470 ms)以上かかって成功した呼び出しを一律に 時間切れとしていた。応答している app でも負荷が高いと 1 回の呼び出しがこの値を超えることがあり、 経路全体が打ち切られて target_unclassified で拒否されていた(PR #821 の CI の deep tree)。 - WindowsV1UiaTimed は、呼び出しが書く出力そのものを受け取り、遅い成功を時間切れとするのは 出力が空のとき(WindowsV1UiaEmpty)だけにする。空の形は型ごとに 1 つ: 要素・pattern・ SAFEARRAY は null、BSTR は null か長さ 0、RECT はすべて 0、値の型(BOOL / int / enum)は 0。 0 が正しい答えでもありうるもの(空の名前、子なし、FALSE、ToggleState_Off など)は、遅ければ 従来どおり時間切れ(fail closed。IsPassword の遅い FALSE を「password ではない」と読まない)。 - 最初の接触(ElementFromHandle / ElementFromPoint / GetFocusedElement)は例外で、遅ければ 要素を返しても時間切れのまま。UI Automation は provider が切れても HWND から要素を作るため (ローカルの live seam で 1026 ms・S_OK・要素ありを観測。成功扱いにすると observe が 2.5 s に 延びた)。 - すべての UIA 呼び出しの呼び出し元が出力を名指しすることを source 契約で数える (ラップ済み 38 == 全 38、渡す出力がその呼び出しの書く変数であること)。 - live seam の C++ に、窓を使わない決定的なケースを足した: 閾値を超えて眠り S_OK と値を返す 呼び出しは成功で経路が続き、空を返す呼び出し(空の BSTR、null の要素、FALSE)と要素を返した 遅い最初の接触は時間切れで経路を終える。 - live seam の 1 ケースの上限を、数えた 1 回の最悪値(Connection 500 + Transaction 300)と 余裕 150 ms の 950 ms にした(host の定数から求め、1 s の Cancel 確認未満を static_assert と 契約で固定)。PR #822 の CI で連鎖が 844 ms で実際に起きて、旧上限(700 ms)で失敗したため。 - host のコメントと ADR の #820 の段落を更新した。 Refs #823 Co-Authored-By: Claude Opus 5.5 --- .../computer_use_windows_host.cc | 169 ++++++++++------ ...e-windows-v1-uia-timeout-live-seam.test.ts | 181 +++++++++++++++--- .../adr-computer-use-agent-driven-v2.md | 2 +- 3 files changed, 257 insertions(+), 95 deletions(-) diff --git a/apps/desktop/computer-use-native/computer_use_windows_host.cc b/apps/desktop/computer-use-native/computer_use_windows_host.cc index dcc40b3c..5475c793 100644 --- a/apps/desktop/computer-use-native/computer_use_windows_host.cc +++ b/apps/desktop/computer-use-native/computer_use_windows_host.cc @@ -69,6 +69,7 @@ #include #include #include +#include #include #include #include @@ -2451,15 +2452,14 @@ std::uint32_t WindowsBuildNumber() { // governs a first contact). 500 ms leaves a cold app time to load its providers on the first call (a // call that still times out is reported as accessibility_timeout, a failure that a new observe does // not repeat once the providers are loaded). A call that waits for the connection and then for the -// transaction is bounded by BOTH: the worst case of one call is counted as 500 + 300 = 800 ms -// (whether the two waits can really follow each other is not established; the sum is the safe -// side), inside the 1 s of the Cancel acknowledgment (COMPUTER_USE_NATIVE_CANCEL_ACK_TIMEOUT_MS). -// The margin is SMALL: 1000 - 800 = about 200 ms for everything else the lane does between the -// epoch moving and the acknowledgment (the pipe, the frame, the answer). Measured on CI: a -// transaction-bound call is cut after 310-320 ms, a first contact after 505-514 ms. The sum of -// these two measurements, taken one at a time, is about 830 ms (the chain itself was not measured; -// 800 ms is the nominal value, not a measured upper bound), which leaves about 170 ms for the pipe, -// the frame and the acknowledgment together; under load it can be less (not verified). +// transaction is bounded by BOTH: the worst case of one call is counted as 500 + 300 = 800 ms, +// inside the 1 s of the Cancel acknowledgment (COMPUTER_USE_NATIVE_CANCEL_ACK_TIMEOUT_MS). The two +// waits DO follow each other: on the CI of PR #822 (2026-10-17) a ClassifyWindowsFocusedElement case +// of the live seam came back after 844 ms. The margin is SMALL: what is left of the 1 s for +// everything else the lane does between the epoch moving and the acknowledgment (the pipe, the frame, +// the answer) was about 156 ms there. Measured on CI one at a time: a transaction-bound call is cut +// after 310-320 ms, a first contact after 505-514 ms. Under more load the margin can be less (not +// verified). The live seam's bound on one case is this counted worst case and 150 ms (950 ms). // 2. The FIRST timed-out call (UIA_E_TIMEOUT or an equivalent failure) ends its path for good: no // UI Automation call after it, fail closed, the "unreadable" of the caller. A target that answers // the first contact and then stops therefore costs one timeout, not one per remaining call. @@ -2478,7 +2478,14 @@ std::uint32_t WindowsBuildNumber() { // systems return a default value for a property when the provider times out (measured on a CI runner: // five property reads of a native provider that does not answer took 1.5 s and all succeeded, with // empty values). So the time of each call is measured, and a call that took within 30 ms of its -// timeout is a timed-out call (WindowsV1UiaTimed), whatever the HRESULT says. +// timeout AND left its output empty (the shape only a cut-off call produces: no element, no string, +// zero; WindowsV1UiaEmpty) is a timed-out call (WindowsV1UiaTimed), whatever the HRESULT says. A slow +// call that did hand back a value has answered: a live app under load can be slow and right, and +// cutting it off would turn it into "unclassified" (#823). Where an empty output is also a real answer +// (an empty name, no child, FALSE), a slow one is still taken for a timeout: fail closed. A first +// contact is the exception: a slow one is a timeout whatever it hands back, because UI Automation +// builds the element of a window from its HWND even when the app's provider was cut off +// (WindowsV1UiaContact). // A failed (timed out, canceled, over the observe budget) read is exactly the "unreadable" of each // caller (stale target, rejected action, or an observation that is not produced): the meaning of V1's // answers is not changed. @@ -2582,36 +2589,72 @@ bool WindowsV1UiaOk(HRESULT result) { return false; } -// A call that took this long and still "succeeded" has been cut off by its timeout (30 ms under it): -// 270 ms against the 300 ms transaction timeout, 470 ms against the 500 ms connection timeout. +// A call that took this long and "succeeded" with an empty output has been cut off by its timeout +// (30 ms under it): 270 ms against the 300 ms transaction timeout, 470 ms against the 500 ms connection +// timeout. constexpr std::chrono::milliseconds kWindowsV1UiaSlowRead{270}; constexpr std::chrono::milliseconds kWindowsV1UiaSlowContact{470}; static_assert(kWindowsV1UiaSlowRead.count() < kWindowsV1UiaTransactionTimeoutMs, "a read is slow before it times out"); static_assert(kWindowsV1UiaSlowContact.count() < kWindowsV1UiaConnectionTimeoutMs, "a contact is slow before it times out"); +// Whether the output of a call has the shape a cut-off call leaves behind (#823): nothing. Measured on +// CI, a timed-out read comes back S_OK with an empty value; a slow call that handed back something real +// has answered. One rule for every type, its zero value: no element / pattern / walker / SAFEARRAY (a +// null pointer), no string (a null or zero-length BSTR), and for the value types, which have no "nothing" +// of their own, the zero a cut-off call leaves (0, FALSE, the first enumerator, an all-zero RECT). A +// process id or a control type of 0 is no answer at all. Where the zero is a real answer too (an element +// with no name, a leaf with no child, the last sibling, a control that is not a password one, +// ToggleState_Off, ExpandCollapseState_Collapsed), a SLOW zero is still taken for a timeout: fail closed, +// as before #823 (IsPassword in particular must never read a cut-off FALSE as "not a password"). A fast +// zero is an answer, whatever it is. A cut-off call that writes nothing leaves the caller's starting +// value: every output starts empty (nullptr, 0, FALSE, an all-zero RECT), except the two pattern states, +// which start at ToggleState_Indeterminate / ExpandCollapseState_LeafNode; a slow one of those is taken +// for an answer here, but the caller rejects exactly those values (semantic_state_unavailable), so both +// shapes of a cut-off state read are refused. +inline bool WindowsV1UiaEmpty(BSTR value) { return SysStringLen(value) == 0; } +template bool WindowsV1UiaEmpty(Pointee *value) { return value == nullptr; } +inline bool WindowsV1UiaEmpty(const RECT &value) { + return value.left == 0 && value.top == 0 && value.right == 0 && value.bottom == 0; +} +template + requires(std::is_arithmetic_v || std::is_enum_v) +bool WindowsV1UiaEmpty(Value value) { + return value == Value{}; +} + // One UI Automation call of a path: the cancel / timeout / budget check first (E_ABORT, no call made, -// when the path has ended), then the call, timed. A call that came back only after nearly its whole -// timeout is a timed-out call (UIA_E_TIMEOUT), even if it says S_OK. -template HRESULT WindowsV1UiaTimed(std::chrono::milliseconds slow, Call &&call) { +// when the path has ended), then the call, timed. `output` is the call's own out-value (read after the +// call). A call that came back only after nearly its whole timeout with an empty output (or, where the +// output proves nothing, with any output) is a timed-out call (UIA_E_TIMEOUT), even if it says S_OK; a +// slow call with a real output is a success. +template +HRESULT WindowsV1UiaTimed(std::chrono::milliseconds slow, bool output_proves_answer, const Value &output, Call &&call) { if (!WindowsV1UiaMayCall()) return E_ABORT; const auto start = std::chrono::steady_clock::now(); HRESULT result = call(); - if (SUCCEEDED(result) && std::chrono::steady_clock::now() - start >= slow) + if (SUCCEEDED(result) && std::chrono::steady_clock::now() - start >= slow && + (!output_proves_answer || WindowsV1UiaEmpty(output))) result = kWindowsV1UiaTimeoutResult; return result; } -// A call on a connected element (walker moves, the parent). -template HRESULT WindowsV1UiaCall(Call &&call) { - return WindowsV1UiaTimed(kWindowsV1UiaSlowRead, call); +// A call on a connected element (walker moves, the parent): a slow one is an answer when it hands back +// an element. +template HRESULT WindowsV1UiaCall(const Value &output, Call &&call) { + return WindowsV1UiaTimed(kWindowsV1UiaSlowRead, true, output, call); } -// The first contact with a target (ElementFromHandle / ElementFromPoint / GetFocusedElement). -template HRESULT WindowsV1UiaContact(Call &&call) { - return WindowsV1UiaTimed(kWindowsV1UiaSlowContact, call); +// The first contact with a target (ElementFromHandle / ElementFromPoint / GetFocusedElement). A slow +// contact is a timeout WHATEVER it hands back (as in #820, kept by #823): UI Automation builds the +// element of a window from its HWND (the host proxy) even when the app's own provider was cut off +// (measured locally: ElementFromHandle on a provider that stopped answering, 1026 ms, S_OK, an element), +// so an element is no proof that the app answered. The output is still named, as every wrapped call names +// its own, but it does not decide. +template HRESULT WindowsV1UiaContact(const Value &output, Call &&call) { + return WindowsV1UiaTimed(kWindowsV1UiaSlowContact, false, output, call); } -// One read of a property: true only for a call that answered in time. -template bool WindowsV1UiaRead(Call &&call) { - return WindowsV1UiaOk(WindowsV1UiaTimed(kWindowsV1UiaSlowRead, call)); +// One read of a property: true only for a call that answered (in time, or slowly with a value). +template bool WindowsV1UiaRead(const Value &output, Call &&call) { + return WindowsV1UiaOk(WindowsV1UiaTimed(kWindowsV1UiaSlowRead, true, output, call)); } // Why the path ended, now (kNone outside a path). @@ -3205,11 +3248,11 @@ bool AppendUiaTree(IUIAutomationElement *element, CONTROLTYPEID control_type = 0; BOOL password = FALSE; const bool attributes_read = - WindowsV1UiaRead([&] { return element->get_CurrentName(&name); }) && - WindowsV1UiaRead([&] { return element->get_CurrentAutomationId(&automation_id); }) && - WindowsV1UiaRead([&] { return element->get_CurrentHelpText(&help); }) && - WindowsV1UiaRead([&] { return element->get_CurrentControlType(&control_type); }) && - WindowsV1UiaRead([&] { return element->get_CurrentIsPassword(&password); }); + WindowsV1UiaRead(name, [&] { return element->get_CurrentName(&name); }) && + WindowsV1UiaRead(automation_id, [&] { return element->get_CurrentAutomationId(&automation_id); }) && + WindowsV1UiaRead(help, [&] { return element->get_CurrentHelpText(&help); }) && + WindowsV1UiaRead(control_type, [&] { return element->get_CurrentControlType(&control_type); }) && + WindowsV1UiaRead(password, [&] { return element->get_CurrentIsPassword(&password); }); if (!attributes_read) { if (name != nullptr) SysFreeString(name); @@ -3263,7 +3306,7 @@ bool AppendUiaTree(IUIAutomationElement *element, *json += ",\"help\":\"" + JsonEscape(help_utf8) + "\""; *json += ",\"children\":["; IUIAutomationElement *child = nullptr; - HRESULT result = WindowsV1UiaCall([&] { return walker->GetFirstChildElement(element, &child); }); + HRESULT result = WindowsV1UiaCall(child, [&] { return walker->GetFirstChildElement(element, &child); }); bool first = true; bool complete = WindowsV1UiaOk(result); while (complete && child != nullptr) { @@ -3273,7 +3316,7 @@ bool AppendUiaTree(IUIAutomationElement *element, if (!AppendUiaTree(child, walker, depth + 1, nodes, json)) complete = false; IUIAutomationElement *next = nullptr; - if (complete && !WindowsV1UiaOk(WindowsV1UiaCall([&] { return walker->GetNextSiblingElement(child, &next); }))) + if (complete && !WindowsV1UiaOk(WindowsV1UiaCall(next, [&] { return walker->GetNextSiblingElement(child, &next); }))) complete = false; child->Release(); child = next; @@ -3322,7 +3365,7 @@ bool ReadUiaObservation(std::uint32_t pid, HWND window, std::string *tree, IUIAutomationElement *root = nullptr; IUIAutomationTreeWalker *walker = nullptr; const HRESULT root_result = - WindowsV1UiaContact([&] { return automation->ElementFromHandle(window, &root); }); + WindowsV1UiaContact(root, [&] { return automation->ElementFromHandle(window, &root); }); const HRESULT walker_result = automation->get_RawViewWalker(&walker); std::string value; std::size_t count = 0; @@ -3447,11 +3490,11 @@ WindowsUiaRisk ClassifyWindowsUiaElementSelf(IUIAutomationElement *element) { CONTROLTYPEID control_type = 0; BOOL password = FALSE; const bool attributes_read = - WindowsV1UiaRead([&] { return element->get_CurrentName(&name); }) && - WindowsV1UiaRead([&] { return element->get_CurrentAutomationId(&automation_id); }) && - WindowsV1UiaRead([&] { return element->get_CurrentHelpText(&help); }) && - WindowsV1UiaRead([&] { return element->get_CurrentControlType(&control_type); }) && - WindowsV1UiaRead([&] { return element->get_CurrentIsPassword(&password); }); + WindowsV1UiaRead(name, [&] { return element->get_CurrentName(&name); }) && + WindowsV1UiaRead(automation_id, [&] { return element->get_CurrentAutomationId(&automation_id); }) && + WindowsV1UiaRead(help, [&] { return element->get_CurrentHelpText(&help); }) && + WindowsV1UiaRead(control_type, [&] { return element->get_CurrentControlType(&control_type); }) && + WindowsV1UiaRead(password, [&] { return element->get_CurrentIsPassword(&password); }); if (!attributes_read) { if (name != nullptr) SysFreeString(name); @@ -3536,7 +3579,7 @@ WindowsUiaRisk ClassifyWindowsUiaElement(IUIAutomationElement *element) { break; IUIAutomationElement *parent = nullptr; const HRESULT parent_result = - WindowsV1UiaCall([&] { return walker->GetParentElement(current, &parent); }); + WindowsV1UiaCall(parent, [&] { return walker->GetParentElement(current, &parent); }); if (!WindowsV1UiaOk(parent_result)) { metadata_complete = false; if (parent != nullptr) @@ -3568,7 +3611,7 @@ WindowsUiaRisk ClassifyWindowsPoint(POINT point, std::uint32_t expected_pid, IUIAutomationElement *element = nullptr; const POINT query = point; const HRESULT found = - WindowsV1UiaContact([&] { return automation->ElementFromPoint(query, &element); }); + WindowsV1UiaContact(element, [&] { return automation->ElementFromPoint(query, &element); }); automation->Release(); if (!WindowsV1UiaOk(found) || element == nullptr) { if (element != nullptr) @@ -3576,7 +3619,7 @@ WindowsUiaRisk ClassifyWindowsPoint(POINT point, std::uint32_t expected_pid, return {}; } int owner_pid = 0; - if (!WindowsV1UiaRead([&] { return element->get_CurrentProcessId(&owner_pid); }) || owner_pid <= 0 || + if (!WindowsV1UiaRead(owner_pid, [&] { return element->get_CurrentProcessId(&owner_pid); }) || owner_pid <= 0 || static_cast(owner_pid) != expected_pid) { element->Release(); return {}; @@ -3599,7 +3642,7 @@ WindowsUiaRisk ClassifyWindowsFocusedElement(std::uint32_t expected_pid) { return {}; IUIAutomationElement *element = nullptr; const HRESULT found = - WindowsV1UiaContact([&] { return automation->GetFocusedElement(&element); }); + WindowsV1UiaContact(element, [&] { return automation->GetFocusedElement(&element); }); automation->Release(); if (!WindowsV1UiaOk(found) || element == nullptr) { if (element != nullptr) @@ -3607,7 +3650,7 @@ WindowsUiaRisk ClassifyWindowsFocusedElement(std::uint32_t expected_pid) { return {}; } int owner_pid = 0; - if (!WindowsV1UiaRead([&] { return element->get_CurrentProcessId(&owner_pid); }) || owner_pid <= 0 || + if (!WindowsV1UiaRead(owner_pid, [&] { return element->get_CurrentProcessId(&owner_pid); }) || owner_pid <= 0 || static_cast(owner_pid) != expected_pid) { element->Release(); return {}; @@ -3629,18 +3672,18 @@ bool ComputeWindowsControlSignature(IUIAutomationElement *element, BSTR automation_id = nullptr; SAFEARRAY *runtime_id = nullptr; const bool facts = - WindowsV1UiaRead([&] { return element->get_CurrentProcessId(&owner_pid); }) && owner_pid > 0 && + WindowsV1UiaRead(owner_pid, [&] { return element->get_CurrentProcessId(&owner_pid); }) && owner_pid > 0 && static_cast(owner_pid) == expected_pid && - WindowsV1UiaRead([&] { return element->get_CurrentControlType(&control_type); }) && - WindowsV1UiaRead([&] { return element->get_CurrentBoundingRectangle(&bounds); }) && + WindowsV1UiaRead(control_type, [&] { return element->get_CurrentControlType(&control_type); }) && + WindowsV1UiaRead(bounds, [&] { return element->get_CurrentBoundingRectangle(&bounds); }) && bounds.right > bounds.left && bounds.bottom > bounds.top && (containing_bounds == nullptr || (bounds.left >= containing_bounds->left && bounds.top >= containing_bounds->top && bounds.right <= containing_bounds->right && bounds.bottom <= containing_bounds->bottom)) && - WindowsV1UiaRead([&] { return element->get_CurrentAutomationId(&automation_id); }) && - WindowsV1UiaRead([&] { return element->GetRuntimeId(&runtime_id); }) && runtime_id != nullptr && + WindowsV1UiaRead(automation_id, [&] { return element->get_CurrentAutomationId(&automation_id); }) && + WindowsV1UiaRead(runtime_id, [&] { return element->GetRuntimeId(&runtime_id); }) && runtime_id != nullptr && SafeArrayGetDim(runtime_id) == 1; if (!facts) { if (automation_id != nullptr) @@ -3699,8 +3742,8 @@ bool ReadWindowsElementTargetId(IUIAutomationElement *element, BSTR automation_id = nullptr; BSTR name = nullptr; const bool read = - WindowsV1UiaRead([&] { return element->get_CurrentAutomationId(&automation_id); }) && - WindowsV1UiaRead([&] { return element->get_CurrentName(&name); }); + WindowsV1UiaRead(automation_id, [&] { return element->get_CurrentAutomationId(&automation_id); }) && + WindowsV1UiaRead(name, [&] { return element->get_CurrentName(&name); }); if (!read) { if (automation_id != nullptr) SysFreeString(automation_id); @@ -3768,7 +3811,7 @@ bool CollectWindowsControlBindings(IUIAutomationElement *element, IUIAutomationElement *child = nullptr; const HRESULT first_result = - WindowsV1UiaCall([&] { return walker->GetFirstChildElement(element, &child); }); + WindowsV1UiaCall(child, [&] { return walker->GetFirstChildElement(element, &child); }); if (!WindowsV1UiaOk(first_result)) { if (child != nullptr) child->Release(); @@ -3782,7 +3825,7 @@ bool CollectWindowsControlBindings(IUIAutomationElement *element, } IUIAutomationElement *next = nullptr; const HRESULT next_result = - WindowsV1UiaCall([&] { return walker->GetNextSiblingElement(child, &next); }); + WindowsV1UiaCall(next, [&] { return walker->GetNextSiblingElement(child, &next); }); child->Release(); if (!WindowsV1UiaOk(next_result)) { if (next != nullptr) @@ -3806,7 +3849,7 @@ bool CaptureWindowsControlBindings(std::uint32_t pid, HWND window, IUIAutomationElement *root = nullptr; IUIAutomationTreeWalker *walker = nullptr; const bool initialized = - WindowsV1UiaOk(WindowsV1UiaContact([&] { return automation->ElementFromHandle(window, &root); })) && + WindowsV1UiaOk(WindowsV1UiaContact(root, [&] { return automation->ElementFromHandle(window, &root); })) && root != nullptr && SUCCEEDED(automation->get_RawViewWalker(&walker)) && walker != nullptr; std::size_t nodes = 0; @@ -3834,7 +3877,7 @@ bool WindowsFocusedElementSignature(std::uint32_t expected_pid, return false; IUIAutomationElement *element = nullptr; const HRESULT found = - WindowsV1UiaContact([&] { return automation->GetFocusedElement(&element); }); + WindowsV1UiaContact(element, [&] { return automation->GetFocusedElement(&element); }); automation->Release(); if (!WindowsV1UiaOk(found) || element == nullptr) { if (element != nullptr) @@ -3879,8 +3922,8 @@ bool FindUiaTargetRecursive(IUIAutomationElement *element, BSTR automation_id = nullptr; BSTR name = nullptr; const bool attributes_read = - WindowsV1UiaRead([&] { return element->get_CurrentAutomationId(&automation_id); }) && - WindowsV1UiaRead([&] { return element->get_CurrentName(&name); }); + WindowsV1UiaRead(automation_id, [&] { return element->get_CurrentAutomationId(&automation_id); }) && + WindowsV1UiaRead(name, [&] { return element->get_CurrentName(&name); }); if (!attributes_read) { if (automation_id != nullptr) SysFreeString(automation_id); @@ -3911,7 +3954,7 @@ bool FindUiaTargetRecursive(IUIAutomationElement *element, } } IUIAutomationElement *child = nullptr; - if (!WindowsV1UiaOk(WindowsV1UiaCall([&] { return walker->GetFirstChildElement(element, &child); }))) { + if (!WindowsV1UiaOk(WindowsV1UiaCall(child, [&] { return walker->GetFirstChildElement(element, &child); }))) { if (child != nullptr) child->Release(); return false; @@ -3923,7 +3966,7 @@ bool FindUiaTargetRecursive(IUIAutomationElement *element, return false; } IUIAutomationElement *next = nullptr; - if (!WindowsV1UiaOk(WindowsV1UiaCall([&] { return walker->GetNextSiblingElement(child, &next); }))) { + if (!WindowsV1UiaOk(WindowsV1UiaCall(next, [&] { return walker->GetNextSiblingElement(child, &next); }))) { if (next != nullptr) next->Release(); child->Release(); @@ -3955,7 +3998,7 @@ bool FindWindowsSemanticTarget(const WindowsSession &session, IUIAutomationTreeWalker *walker = nullptr; const WindowsV1UiaPath path; const bool initialized = - WindowsV1UiaOk(WindowsV1UiaContact([&] { return automation->ElementFromHandle(session.active_window, &root); })) && + WindowsV1UiaOk(WindowsV1UiaContact(root, [&] { return automation->ElementFromHandle(session.active_window, &root); })) && root != nullptr && SUCCEEDED(automation->get_RawViewWalker(&walker)) && walker != nullptr; std::size_t visited = 0; @@ -4005,7 +4048,7 @@ bool Utf16FromUtf8(std::string_view value, std::wstring *output) { template bool ReadWindowsV1Pattern(IUIAutomationElement *target, PATTERNID id, Pattern **pattern) { *pattern = nullptr; - const bool read = WindowsV1UiaRead([&] { return target->GetCurrentPatternAs(id, IID_PPV_ARGS(pattern)); }); + const bool read = WindowsV1UiaRead(*pattern, [&] { return target->GetCurrentPatternAs(id, IID_PPV_ARGS(pattern)); }); if (!read || *pattern == nullptr) { if (*pattern != nullptr) (*pattern)->Release(); @@ -4099,7 +4142,7 @@ bool DispatchWindowsSemanticAction(const WindowsSession &session, std::string selected_value; IUIAutomationSelectionItemPattern *pattern = nullptr; BSTR name = nullptr; - const bool name_read = WindowsV1UiaRead([&] { return target->get_CurrentName(&name); }); + const bool name_read = WindowsV1UiaRead(name, [&] { return target->get_CurrentName(&name); }); const std::string target_name = name == nullptr || !name_read ? std::string{} @@ -4138,7 +4181,7 @@ bool DispatchWindowsSemanticAction(const WindowsSession &session, *reason = "semantic_pattern_unavailable"; } else { ToggleState current = ToggleState_Indeterminate; - if (!WindowsV1UiaRead([&] { return pattern->get_CurrentToggleState(¤t); }) || + if (!WindowsV1UiaRead(current, [&] { return pattern->get_CurrentToggleState(¤t); }) || current == ToggleState_Indeterminate) { *reason = "semantic_state_unavailable"; } else if ((current == ToggleState_On) == wants_true) { @@ -4168,7 +4211,7 @@ bool DispatchWindowsSemanticAction(const WindowsSession &session, *reason = "semantic_pattern_unavailable"; } else { ExpandCollapseState current = ExpandCollapseState_LeafNode; - if (!WindowsV1UiaRead([&] { return pattern->get_CurrentExpandCollapseState(¤t); }) || + if (!WindowsV1UiaRead(current, [&] { return pattern->get_CurrentExpandCollapseState(¤t); }) || current == ExpandCollapseState_LeafNode) { *reason = "semantic_state_unavailable"; } else if ((current == ExpandCollapseState_Expanded) == wants_expand) { diff --git a/apps/desktop/src/main/computer-use-windows-v1-uia-timeout-live-seam.test.ts b/apps/desktop/src/main/computer-use-windows-v1-uia-timeout-live-seam.test.ts index 353db0fb..6b317a99 100644 --- a/apps/desktop/src/main/computer-use-windows-v1-uia-timeout-live-seam.test.ts +++ b/apps/desktop/src/main/computer-use-windows-v1-uia-timeout-live-seam.test.ts @@ -13,11 +13,11 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest'; // // What the live seam measures: every case below stops at ONE call, cut off either by the // ConnectionTimeout (a first contact, measured 505-514 ms in CI) or by the TransactionTimeout (a call on -// a connected element, 310-320 ms). The bound is therefore one ConnectionTimeout and a 200 ms margin. -// The 800 ms (Connection + Transaction in ONE call) that the ADR counts against the 1 s Cancel -// acknowledgment is a safe upper estimate that has not been observed; if a call ever did run both waits, -// it would exceed this bound and the seam would fail, which is the alarm wanted. No case here sets out to -// cause that chain, so the 800 ms itself is not verified (a known limit, as the ADR says). +// a connected element, 310-320 ms), or by both in ONE call: the 800 ms (Connection + Transaction) that the +// ADR counts against the 1 s Cancel acknowledgment does happen (CI of PR #822, 2026-10-17: a +// ClassifyWindowsFocusedElement case took 844 ms, about 156 ms short of the 1 s). The bound is therefore +// that counted worst case of one call and a 150 ms margin (950 ms), strictly under the 1 s; a case that +// made a second slow call would exceed it. // // The live seam compiles the real host (computer_use_windows_host.cc, with its entry point renamed) into a // test program. The program starts ITSELF as a second process that owns the windows (so every UI @@ -25,7 +25,7 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest'; // answering altogether, an ordinary one, and one whose native UI Automation provider answers a few // property requests and then stops. The V1 paths are called against them (the point and focus // classification, the observation, the control bindings, the semantic target search, the focused-element -// signature that the re-validation uses). Each must come back within one timeout plus a small margin and +// signature that the re-validation uses). Each must come back within one call (its worst case) and a margin and // fail closed. Windows only: elsewhere, or where no MSVC can be found, it is skipped (but never in CI, // where a runner that cannot compile must not turn this into a silent pass). The build output goes to a // temporary directory that is removed after the run. @@ -161,6 +161,17 @@ describe('V1 lane UI Automation is created in one place, with its call timeouts // case is counted as the SUM of the two (the safe side), and a Cancel waits for at most that one // call (the epoch is asked before every call): it is inside the acknowledgment. expect(connection + transaction).toBeLessThan(cancelAck); + // The live seam's bound on each case is that worst case of one call and a margin, from the same + // constants, and it stays strictly inside the acknowledgment (the chain was observed on CI at 844 ms). + const seamMargin = Number( + /constexpr long long kBound = kOneCall \+ (\d+);/u.exec(program)![1]!, + ); + expect(program).toContain( + 'static_cast(kWindowsV1UiaConnectionTimeoutMs) + static_cast(kWindowsV1UiaTransactionTimeoutMs);', + ); + expect(seamMargin).toBeGreaterThan(0); + expect(connection + transaction + seamMargin).toBeLessThan(cancelAck); + expect(program).toContain('static_assert(kBound < 1000,'); // The first contact is allowed to be slower than a call on a connected element. expect(connection).toBeGreaterThanOrEqual(transaction); // There is NO time budget on a path: a deep tree of a live app must not become "unclassified". @@ -216,7 +227,7 @@ describe('V1 lane UI Automation is created in one place, with its call timeouts climb.indexOf('walker->GetParentElement(current, &parent)'), ); expect(climb).toContain( - 'WindowsV1UiaCall([&] { return walker->GetParentElement(current, &parent); })', + 'WindowsV1UiaCall(parent, [&] { return walker->GetParentElement(current, &parent); })', ); // The point classification reads the signature only for facts that are complete. expect(c).toContain( @@ -306,7 +317,7 @@ describe('V1 lane UI Automation is created in one place, with its call timeouts expect(order.every((at) => at >= 0)).toBe(true); expect([...order].sort((a, b) => a - b)).toEqual(order); expect(c).toContain( - 'template HRESULT WindowsV1UiaCall(Call &&call) { return WindowsV1UiaTimed(kWindowsV1UiaSlowRead, call); }', + 'template HRESULT WindowsV1UiaCall(const Value &output, Call &&call) { return WindowsV1UiaTimed(kWindowsV1UiaSlowRead, true, output, call); }', ); // EVERY call that reaches the target goes through WindowsV1UiaCall: the first contact, the walks // (first child, next sibling) and the climb to the parent. The reads of properties are asked at the @@ -318,7 +329,7 @@ describe('V1 lane UI Automation is created in one place, with its call timeouts ]; const guarded = [ ...c.matchAll( - /WindowsV1Uia(?:Call|Contact)\(\[&\] \{ return \w+->(ElementFromPoint|ElementFromHandle|GetFocusedElement|GetFirstChildElement|GetNextSiblingElement|GetParentElement)\(/gu, + /WindowsV1Uia(?:Call|Contact)\(\w+, \[&\] \{ return \w+->(ElementFromPoint|ElementFromHandle|GetFocusedElement|GetFirstChildElement|GetNextSiblingElement|GetParentElement)\(/gu, ), ]; expect(calls.length).toBeGreaterThanOrEqual(12); @@ -326,7 +337,7 @@ describe('V1 lane UI Automation is created in one place, with its call timeouts // The first contacts use the contact wrapper (the longer slow threshold), the moves the call one. for (const name of ['ElementFromPoint', 'ElementFromHandle', 'GetFocusedElement']) expect(c, name).not.toMatch( - new RegExp(`WindowsV1UiaCall\\(\\[&\\] \\{ return \\w+->${name}\\(`, 'u'), + new RegExp(`WindowsV1UiaCall\\(\\w+, \\[&\\] \\{ return \\w+->${name}\\(`, 'u'), ); // EVERY call of a UI Automation interface in the host, whatever the variable is called (element, // target, pattern, walker, automation, ...), goes through a wrapper of the V1 lane: the reads of @@ -335,26 +346,41 @@ describe('V1 lane UI Automation is created in one place, with its call timeouts // answer, or go on after a Cancel. const uiaNames = 'get_Current\\w+|get_Cached\\w+|GetCurrentPattern\\w*|GetCachedPattern\\w*|GetCurrentPropertyValue\\w*|GetCachedPropertyValue\\w*|BuildUpdatedCache|GetRuntimeId|ElementFrom\\w+|FindFirst\\w*|FindAll\\w*|GetFocusedElement|GetFirstChildElement|GetNextSiblingElement|GetPreviousSiblingElement|GetLastChildElement|GetParentElement'; + // Each wrapper is handed the call's own out-value first (#823): it decides whether a slow success + // is a cut-off one (empty) or an answer. + const wrappedUiaCall = new RegExp( + `WindowsV1Uia(?:Read|Call|Contact)\\((\\*?\\w+), \\[&\\] \\{ return \\w+->(?:${uiaNames})\\(([^;]*)\\); \\}\\)`, + 'gu', + ); const bareUiaCalls = (text: string): number => [...text.matchAll(new RegExp(`->(?:${uiaNames})\\(`, 'gu'))].length - - [ - ...text.matchAll( - new RegExp( - `WindowsV1Uia(?:Read|Call|Contact)\\(\\[&\\] \\{ return \\w+->(?:${uiaNames})\\(`, - 'gu', - ), - ), - ].length; - // The check itself catches a bare call (a wrapped one and a bare one of the same method). + [...text.matchAll(wrappedUiaCall)].length; + // The output a wrapper is handed is the one the call writes: "&x" in its arguments, or + // IID_PPV_ARGS(x) for "*x" (a pattern). Calls that hand it something else are counted. + const misnamedOutputs = (text: string): number => + [...text.matchAll(wrappedUiaCall)].filter(([, output, args]) => + output!.startsWith('*') + ? !args!.includes(`IID_PPV_ARGS(${output!.slice(1)})`) + : !new RegExp(`&${output}\\b`, 'u').test(args!), + ).length; + // The checks themselves catch a bare call (a wrapped one and a bare one of the same method), a + // wrapper without its output (the form before #823), and a wrapper handed another variable. expect( bareUiaCalls( - 'WindowsV1UiaRead([&] { return element->get_CurrentName(&n); }); element->get_CurrentName(&n);', + 'WindowsV1UiaRead(n, [&] { return element->get_CurrentName(&n); }); element->get_CurrentName(&n);', ), ).toBe(1); - expect([...c.matchAll(new RegExp(`->(?:${uiaNames})\\(`, 'gu'))].length).toBeGreaterThanOrEqual( - 35, - ); + expect(bareUiaCalls('WindowsV1UiaRead([&] { return element->get_CurrentName(&n); });')).toBe(1); + expect( + misnamedOutputs( + 'WindowsV1UiaRead(m, [&] { return element->get_CurrentName(&n); }); WindowsV1UiaRead(*q, [&] { return t->GetCurrentPatternAs(id, IID_PPV_ARGS(p)); });', + ), + ).toBe(2); + const allUiaCalls = [...c.matchAll(new RegExp(`->(?:${uiaNames})\\(`, 'gu'))].length; + expect(allUiaCalls).toBeGreaterThanOrEqual(38); + expect([...c.matchAll(wrappedUiaCall)]).toHaveLength(allUiaCalls); expect(bareUiaCalls(c)).toBe(0); + expect(misnamedOutputs(c)).toBe(0); // The calls that DO something (Invoke, SetValue, Select, Toggle, Expand, Collapse) are made only // while the path is alive: after a timeout or a Cancel nothing is sent (a rejection before any input). expect([...c.matchAll(/->(Invoke|SetValue|Select|Toggle|Expand|Collapse)\(/gu)]).toHaveLength( @@ -387,7 +413,7 @@ describe('V1 lane UI Automation is created in one place, with its call timeouts ]).toHaveLength(5); expect(c).not.toMatch(/target->GetCurrentPatternAs\(UIA_/u); expect(c).toContain( - 'const bool name_read = WindowsV1UiaRead([&] { return target->get_CurrentName(&name); });', + 'const bool name_read = WindowsV1UiaRead(name, [&] { return target->get_CurrentName(&name); });', ); expect(c).toContain('if (!name_read) {'); // A child that a cut-off first-child call produced is released (no output pointer is leaked). @@ -397,10 +423,38 @@ describe('V1 lane UI Automation is created in one place, with its call timeouts expect(readFileSync(join(nativeDirectory, 'computer_use_protocol_fuzz.cc'), 'utf8')).toContain( 'v1-default-risk-is-unclassified', ); - // The time of a call is measured, and a slow "success" is a timeout. + // The time of a call is measured, and a slow "success" is a timeout only when its output is empty + // (the shape of a cut-off call); a slow success with a value is a success (#823). A first contact is + // the exception: its element is built from the HWND even when the app was cut off, so a slow contact + // stays a timeout whatever it hands back (as in #820). expect(c).toContain( - 'if (SUCCEEDED(result) && std::chrono::steady_clock::now() - start >= slow) result = kWindowsV1UiaTimeoutResult;', + 'if (SUCCEEDED(result) && std::chrono::steady_clock::now() - start >= slow && (!output_proves_answer || WindowsV1UiaEmpty(output))) result = kWindowsV1UiaTimeoutResult;', ); + expect(c).not.toContain('std::chrono::steady_clock::now() - start >= slow)'); + expect(c).toContain( + 'template HRESULT WindowsV1UiaContact(const Value &output, Call &&call) { return WindowsV1UiaTimed(kWindowsV1UiaSlowContact, false, output, call); }', + ); + expect(c).toContain( + 'template bool WindowsV1UiaRead(const Value &output, Call &&call) { return WindowsV1UiaOk(WindowsV1UiaTimed(kWindowsV1UiaSlowRead, true, output, call)); }', + ); + // The three wrappers are the only callers of the timed call. + expect([...c.matchAll(/WindowsV1UiaTimed\(kWindowsV1UiaSlow/gu)]).toHaveLength(3); + // What "empty" is, per type: a null or zero-length BSTR (an empty name stays fail closed), a null + // pointer (element, pattern, walker, SAFEARRAY), an all-zero RECT, and the zero of a value type. + expect(c).toContain( + 'inline bool WindowsV1UiaEmpty(BSTR value) { return SysStringLen(value) == 0; }', + ); + expect(c).toContain( + 'template bool WindowsV1UiaEmpty(Pointee *value) { return value == nullptr; }', + ); + expect(c).toContain( + 'inline bool WindowsV1UiaEmpty(const RECT &value) { return value.left == 0 && value.top == 0 && value.right == 0 && value.bottom == 0; }', + ); + expect(c).toContain( + 'template requires(std::is_arithmetic_v || std::is_enum_v) bool WindowsV1UiaEmpty(Value value) { return value == Value{}; }', + ); + // The overloads are the only ones (no other type gets its own idea of "empty"). + expect([...c.matchAll(/bool WindowsV1UiaEmpty\(/gu)]).toHaveLength(4); // The frames of a preflight and of a dispatch each run under one outer path, so the later steps of // a frame share the state of the first timed-out call and of a Cancel. expect(c).toContain('const WindowsV1UiaPath preflight_uia_path; if (!HandleWindowsPreflight('); @@ -849,10 +903,13 @@ int RunDriver(bool ci) { Sleep(600); std::cout << "child pid=" << pid << std::endl; - const long long kOneTimeout = static_cast(kWindowsV1UiaConnectionTimeoutMs); - // One timeout (the longer, the connection) and a small margin. Every case stops at one call; the - // counted 800 ms worst case (both waits in one call) is not expected here and would fail this bound. - const long long kBound = kOneTimeout + 200; + // The counted worst case of ONE call (the connection wait and then the transaction wait, observed on CI + // at 844 ms) and a margin, strictly inside the 1 s Cancel acknowledgment of Main. Every case stops at + // one call; a second call would exceed it. + constexpr long long kOneCall = + static_cast(kWindowsV1UiaConnectionTimeoutMs) + static_cast(kWindowsV1UiaTransactionTimeoutMs); + constexpr long long kBound = kOneCall + 150; + static_assert(kBound < 1000, "one call and its margin stay inside the 1 s Cancel acknowledgment"); const long long kHeldUp = 250; // a call that came back sooner was never held up // The very first call of this new process, to a window of another process, reads it: a cold start @@ -971,12 +1028,74 @@ int RunDriver(bool ci) { } { // outside any path a call is refused (fail closed): no call is made, and an action is not sent bool called = false; - const HRESULT refused = WindowsV1UiaCall([&] { called = true; return S_OK; }); + const HRESULT refused = WindowsV1UiaCall(called, [&] { called = true; return S_OK; }); SeamCheck(refused == E_ABORT && !called && !WindowsV1UiaOk(refused), "a_call_outside_any_path_is_refused_without_being_made"); SeamCheck(!WindowsV1UiaMayCall() && std::string(WindowsV1UiaEndedReason()) == "semantic_action_failed", "an_action_outside_any_path_is_not_sent"); SeamCheck(!WindowsV1UiaIsTimeout(E_ABORT), "a_refused_call_is_not_a_timeout"); SeamCheck(!WindowsV1UiaIsTimeout(E_FAIL), "the_initial_result_of_an_action_not_sent_is_not_a_timeout"); } + { // a slow success is a timeout only when its output is empty (#823): calls that take just over the + // slow threshold and say S_OK, with a value and without one (no window: the timing is the seam's own) + const auto slow_by = [](std::chrono::milliseconds slow) { + const auto until = std::chrono::steady_clock::now() + slow + std::chrono::milliseconds(10); + while (std::chrono::steady_clock::now() < until) Sleep(5); + }; + { + const WindowsV1UiaPath path; + BSTR fast_empty = nullptr; + const bool fast = WindowsV1UiaRead(fast_empty, [&] { fast_empty = SysAllocString(L""); return S_OK; }); + SysFreeString(fast_empty); + SeamCheck(fast && path.Stop() == WindowsV1UiaStop::kNone, "a_fast_empty_read_is_an_answer"); + BSTR name = nullptr; + const bool named = WindowsV1UiaRead(name, [&] { slow_by(kWindowsV1UiaSlowRead); name = SysAllocString(L"answer"); return S_OK; }); + SysFreeString(name); + SeamCheck(named && path.Stop() == WindowsV1UiaStop::kNone && WindowsV1UiaMayCall(), "a_slow_read_with_a_value_is_a_success_and_the_path_lives"); + CONTROLTYPEID control_type = 0; + const bool typed = WindowsV1UiaRead(control_type, [&] { slow_by(kWindowsV1UiaSlowRead); control_type = UIA_ButtonControlTypeId; return S_OK; }); + SeamCheck(typed && path.Stop() == WindowsV1UiaStop::kNone, "a_slow_value_type_read_with_a_value_is_a_success"); + int found_target = 0; + int *found = nullptr; + const HRESULT moved = WindowsV1UiaCall(found, [&] { slow_by(kWindowsV1UiaSlowRead); found = &found_target; return S_OK; }); + SeamCheck(moved == S_OK && WindowsV1UiaOk(moved) && path.Stop() == WindowsV1UiaStop::kNone, "a_slow_move_with_an_element_is_a_success"); + BSTR empty = nullptr; + const bool emptied = WindowsV1UiaRead(empty, [&] { slow_by(kWindowsV1UiaSlowRead); empty = SysAllocString(L""); return S_OK; }); + SysFreeString(empty); + SeamCheck(!emptied && path.Stop() == WindowsV1UiaStop::kTimeout, "a_slow_read_with_an_empty_value_is_a_timeout_and_ends_the_path"); + bool called_after = false; + const HRESULT after = WindowsV1UiaCall(called_after, [&] { called_after = true; return S_OK; }); + SeamCheck(after == E_ABORT && !called_after, "no_call_is_made_after_the_slow_empty_read"); + } + { // ...and a slow call that hands back no element is a timeout too + const WindowsV1UiaPath path; + int *element = nullptr; + const HRESULT result = WindowsV1UiaCall(element, [&] { slow_by(kWindowsV1UiaSlowRead); element = nullptr; return S_OK; }); + SeamCheck(WindowsV1UiaIsTimeout(result) && !WindowsV1UiaOk(result) && path.Stop() == WindowsV1UiaStop::kTimeout, "a_slow_call_without_an_element_is_a_timeout"); + } + { // ...and a slow first contact is a timeout even WITH an element (UI Automation builds it from the + // HWND when the app's provider was cut off), while a fast one is an answer + const WindowsV1UiaPath path; + int element_target = 0; + int *fast_element = nullptr; + const HRESULT fast = WindowsV1UiaContact(fast_element, [&] { fast_element = &element_target; return S_OK; }); + SeamCheck(WindowsV1UiaOk(fast) && path.Stop() == WindowsV1UiaStop::kNone, "a_fast_contact_is_an_answer"); + int *element = nullptr; + const HRESULT result = WindowsV1UiaContact(element, [&] { slow_by(kWindowsV1UiaSlowContact); element = &element_target; return S_OK; }); + SeamCheck(WindowsV1UiaIsTimeout(result) && !WindowsV1UiaOk(result) && path.Stop() == WindowsV1UiaStop::kTimeout, "a_slow_contact_with_an_element_is_still_a_timeout"); + } + { // ...and a slow FALSE: a value type has no "nothing" of its own, so its zero stays fail closed + // (a cut-off IsPassword must never read as "not a password") + const WindowsV1UiaPath path; + BOOL password = FALSE; + const bool read = WindowsV1UiaRead(password, [&] { slow_by(kWindowsV1UiaSlowRead); password = FALSE; return S_OK; }); + SeamCheck(!read && path.Stop() == WindowsV1UiaStop::kTimeout, "a_slow_false_is_a_timeout"); + } + { // ...while a slow TRUE is an answer + const WindowsV1UiaPath path; + BOOL password = FALSE; + const bool read = WindowsV1UiaRead(password, [&] { slow_by(kWindowsV1UiaSlowRead); password = TRUE; return S_OK; }); + SeamCheck(read && password == TRUE && path.Stop() == WindowsV1UiaStop::kNone, "a_slow_true_is_an_answer"); + } + } { // a timeout inside an inner path ends the outer one: the later reads of the frame make no call const WindowsV1UiaPath frame; WindowsUiaRisk risk; diff --git a/tasks/designs/adr-computer-use-agent-driven-v2.md b/tasks/designs/adr-computer-use-agent-driven-v2.md index c7b3c543..69b37db4 100644 --- a/tasks/designs/adr-computer-use-agent-driven-v2.md +++ b/tasks/designs/adr-computer-use-agent-driven-v2.md @@ -666,7 +666,7 @@ computer_start がここで返る → { sessionId, state, stopReason, mode, roun **S5-c-2 PR-3(Explorer の実ファイル名。2026-10-17。動作不変)**: 上の S5-c (2) の「Explorer の項目を表示名で探す」を、ビューの項目の走査から、フォルダのパスと列挙に替えた。**照合は規則ではなく、シェルが実際に出す表示名で行う**(拡張子を隠す設定、未登録の拡張子、`.lnk` / `.website`、ドットを含むフォルダ名、ローカライズ名はシェルの答えに任せ、固定の拡張子リストと語幹の規則は持たない)。`ShellItemPathByName` は view を特定した後、次の順で進める。(1) **入口で外す場所(どれも列挙の前。各 I/O の前に取消しと期限を確かめる)**: ID リストが空(デスクトップ名前空間のルート。ビューは Public\Desktop も統合して見せる)、`SFGAO_ISSLOW`、ファイルシステムのディレクトリでない場所、`FOLDERID_Desktop` / `FOLDERID_PublicDesktop` のパス(`KF_FLAG_DONT_VERIFY` で尋ねる。実機では未確認の保険)。ネットワークは 3 段階で外す: ①ドライブ文字の種類(`GetDriveTypeW` を先頭 3 文字に。I/O なし。応答しない SMB 共有に割り当てたドライブには触れない)、②パスの各要素をドライブのルートから見て、symbolic link は拒否し、mount point(ジャンクション)は `FSCTL_GET_REPARSE_POINT` で行き先を読み、ドライブ文字のローカルディスクを指し、かつ行き先のパスの各要素をドライブのルートから見てリンク(symlink、別のジャンクションを含む)が 1 つも無いときだけ許す(行き先の途中が UNC への symlink でありうるため。再帰はせず深さ 1)(`\??\Volume{...}` のようなボリューム指定は不明のまま拒否)、③その後に `GetVolumePathNameW` のボリュームを同じ判定にかける(マウントしたボリュームのルートそのものはパスより長くなるので判定なし)。リンクの判定は `IsReparseTagNameSurrogate`(タグの bit 29)で、クラウドファイルのタグには立たないので許される。経路の途中のジャンクションにも同じ緩和を当てた(ジャンクションの行き先はドライブ文字のパスかボリュームで UNC は指せず、行き先のパスの各要素も上のとおり I/O して確かめる)。desktop.ini に `[LocalizedFileNames]` があるフォルダは判定なし: シェルは読み取り専用か system のフォルダでだけ desktop.ini を使う(実機で確認)ので、そのときだけ読む。ファイルは `CreateFileW(FILE_FLAG_OPEN_REPARSE_POINT)` で 1 回だけ開き、同じハンドルで reparse の有無(種類を問わず「あり」)、大きさの上限(64 KB)、読み取りまでを行い、UTF-16(LE / BE)か ANSI を自前のパーサで判定する(`GetPrivateProfileSectionW` は使わない。パスで開き直してリンクをたどるため)。セクションが空かは、次のセクションまでに空行とコメント以外の行があるかで決め、判定できないものは「あり」とする。(2) フォルダを `FindFirstFileExW(フォルダ\*)` で 1 回列挙する(メタデータだけ。時間は `kShellLookupBudgetNs`)。シェル表示名を取る候補は、ファイル名が表示名で始まるものと、読み取り専用または system のディレクトリ(desktop.ini の LocalizedResourceName で別名になるのはこのフォルダだけ)の合計で、64 件を超えたら切り捨てずに全体を判定なしにする。隠しファイルは外さない。新しい列挙は隠し項目も候補に含めるので、Explorer に表示されない項目にも I/O が発生しうる。ただし、その I/O は上の確認を通ったローカルのものだけ。候補がリンク(symlink、行き先がローカルでない mount point)だと、見つけた時点で全体を判定なしにし、残りの I/O は省く。(3) 候補ごとに、解決先(`SIGDN_FILESYSPATH` の末尾)が候補名と一致するときだけ、`SHCreateItemFromParsingName` と `GetDisplayName(SIGDN_NORMALDISPLAY)` で表示名を取る(末尾がドットや空白の名前は Win32 が別のエントリに正規化するので採らない)。読み取り専用 / system の候補ディレクトリは、シェルがその desktop.ini を読んで名前を決める(リンクをたどる、リソース参照のモジュールを読み込む、CLSID を bind する)ので、先に同じ方式(ハンドルで 1 回)で読み、リンク、読めない、判定できない、`[.ShellClassInfo]` の `CLSID` / `CLSID2` / `UICLSID` がある、`LocalizedResourceName` / `IconResource` / `IconFile` / `InfoTip`(と Logo など)の値が許可リストに無い、のどれかなら、シェルには尋ねず unknown にする。許可するのは、外側の引用符(単一・二重)と空白を外した値が (a) `\ / : % @ ,` を含まないプレーンテキスト、または (b) `%SystemRoot%` / `%windir%` / `%ProgramFiles%` / `%ProgramFiles(x86)%` / `%CommonProgramFiles%` で始まる(先頭に `@`、末尾に `,-N` か `,N` があってもよい)パスで、`/`、連続した `\`、`..`、ほかの `%` を含まないもの、の 2 つだけ。`X:\` で始まる絶対パスは、途中が UNC への symbolic link でありうるので許可しない。ただし環境変数の展開先のフォルダにリンクがある可能性は残る(**既知の限界**。確認していない)。portable の `MatchShellDisplayName` は、表示名がちょうど 1 件に一致し、候補にリンクも読めないものも無いときだけ、その実パスを返す。拡張子の表示設定は読まない。`is_folder` は `FILE_ATTRIBUTE_DIRECTORY` から取る。(4) lookup のスレッドは同期のファイルシステム I/O を発行する。取消しと期限は、新しい I/O を始める直前ごと(入口のシェル呼び出し、場所の判定、リンク、desktop.ini の open / 情報 / 読み取り、最初の列挙、各候補)に確かめる。待ちが切れた側は lookup に放棄を知らせ、スレッドの複製ハンドルへ `CancelSynchronousIo` を送ってブロックした呼び出しを打ち切る。ドライバが戻らない場合は取り消せず、そのとき lookup の枠が埋まり続けて以降の lookup は判定なしになる(V1 に任せる)のは**既知の限界**。確認してから列挙や desktop.ini の open までの間に、経路の途中の要素がリンクへ差し替えられる競合(TOCTOU)も残る(`FILE_FLAG_OPEN_REPARSE_POINT` が守るのは末尾の desktop.ini だけ)。これは攻撃者が同じユーザーでローカルの操作をできる場合に限られる残余のリスクで、同じユーザーのローカルの操作に限られ、候補は FILESYSPATH の末尾で自分自身と確かめられ、dispatch 直前に再評価される(PR-5)ので許容する。lookup のスレッドは例外で process を落とさず、COM の解放、枠の解放、待っている側への通知は必ず通る。UIA 側では、ListItem が shell の項目 view(`SHELLDLL_DefView` または `UIItemsView`)の中にあるときだけ尋ね、プレビューペインや検索候補の ListItem は実パス無し(kIncomplete)とする。(5) **常に判定なしになる場所**(fail closed の代償)。開発機で、実フォルダを読み取りだけで確かめた結果: Documents、Downloads、C:\ProgramData、C:\Program Files は判定できる(Documents の Hidden + System のジャンクション 3 個、ProgramData の 5 個は行き先がローカルなので通る)。判定なしになるのは、ユーザーのホーム(互換用のジャンクションのうち Recent は desktop.ini を開けず unknown になる。ほかの 21 件は通る)、C:\Windows(Downloaded Program Files、Fonts、Offline Web Pages の 3 つが `CLSID` を持つ名前空間拡張のフォルダで、方針どおり unknown)、C:\Users(All Users が symbolic link)。C:\\ のルートは判定できる(読み取りのみで実測。Windows、Program Files が取れた。ルートでは directory の区切りを二重にしていたため全候補が unknown になる不具合があり、`ShellDirectoryWithSeparator` で直した)。OneDrive の desktop.ini がクラウドファイル(reparse point)なら、種類を問わず unknown になる(この機で OneDrive は通った)。アクセス拒否で desktop.ini が開けない候補を「シェルも読めないので安全」とみなす緩和は、fail closed を保つため行っていない。(6) 実測値の読み方: 以前の「ホームで 22 件すべて表示名が取れた」は、リンクの候補を区別する前の数字で、現在の規則では上記のとおり。時間は自作の一時フォルダで、項目数 4 / 120 / 500 / 5,000 のどれでも実パスが取れ、1 回 10〜14 ms 程度(リンクの確認と候補の desktop.ini の確認を含む。最大 21 ms)で、項目数への依存は見えなかった。(7) (1) の旧記述との食い違いの整理: 以前は「symbolic link / mount point を一律に外す」と書き、その結果ホームなどで常に判定なしになることを (7) に書いていたが、現在は mount point の行き先がローカルのドライブ文字パスなら通す(symbolic link と行き先が不明のものは外す)。lookup の待ち(150 ms)、PR-1 で分けた枠の引数、実パスが無いファイル項目は kIncomplete にする規則(S5-c-1 (2))は変えていない。実機で確かめたもの: `x` と `x.website`(判定なし)、LICENSE と LICENSE.md、フォルダ `data` と `data.xxx`、`[LocalizedFileNames]` のある desktop.ini(UTF-16 と ANSI、読み取り専用のフォルダで判定なし、通常のフォルダでは無視される)、コメントだけの節(取れる)、desktop.ini で別名になる読み取り専用フォルダ、末尾がドットの名前、ジャンクション(行き先がローカルなら通る)、UNC のリソース参照や CLSID を持つ候補ディレクトリ(判定なし)。desktop.ini の symbolic link は権限がなく作れなかったので、harness と source 契約のみで固定している。拡張子を隠す設定と Desktop / Public Desktop は実機で未確認。 -**V1 のレーンの UI Automation にタイムアウトを付けた(#820。S5-c-2 の計画で範囲外としていた R8)**: V1 自身がレーン(直列の処理スレッド)の上で呼ぶ UI Automation(observe の木の読み取り、点とフォーカスの分類、control bindings、semantic target の検索、再検証が使うフォーカス要素の署名)にはタイムアウトが無く、操作の対象が応答しなくなると呼び出しが戻らず、その後の observe、preflight、dispatch、Cancel がすべて進まなくなった。V1 の UIA は `CreateWindowsV1Automation` の 1 か所で作り(8 か所の `CoCreateInstance(CLSID_CUIAutomation, ...)` をまとめた)、shadow の `WindowsUiaShadowSource::Create` と共通の `CreateBoundedAutomation`(`CLSID_CUIAutomation8` + `IUIAutomation2`)で ConnectionTimeout / TransactionTimeout を設定する。`IUIAutomation2` が取れないときは従来どおりのタイムアウトの無い `CLSID_CUIAutomation` に戻り(コード上の直接生成はこのフォールバック 1 か所だけ)、回数を数えて初回だけ `OutputDebugString` に記録する。**3 つの規則**。(1) 呼び出し 1 回を縛る: ConnectionTimeout は 500 ms、TransactionTimeout は 300 ms。応答しない窓で実測すると、最初の接触(`ElementFromHandle` / `ElementFromPoint` / `GetFocusedElement`)を縛るのは ConnectionTimeout のほうで(300 に設定して 311 ms、2000 で 2001 ms。TransactionTimeout を変えても変わらない)、接続済みの要素への呼び出しを縛るのが TransactionTimeout である。接続は cold な app が provider を読み込む最初の呼び出しでもあるので 500 ms にして生きている app を「読めない」にしにくくし、接続後の呼び出しは 300 ms で足りる。1 回の呼び出しが接続の待ちのあとに transaction の待ちも続けて起こしうるかは確かめていないので、**1 回の最悪値は安全側に 500 + 300 = 800 ms と数える**。(2) 最初に時間切れ(`UIA_E_TIMEOUT`、`ERROR_TIMEOUT`、`RPC_E_TIMEOUT`)になった呼び出しで、その経路(`WindowsV1UiaPath`)を終わらせ、その後は UI Automation を一切呼ばない。最初の接触に答えてから止まる対象は、残りの呼び出しの数ではなく、時間切れ 1 回分で済む。(3) Cancel が届いた(`cancellation_epoch` が経路の開始時から動いた)ら、同じく経路を終わらせる。確認は**すべての呼び出しの前**(`WindowsV1UiaCall` と、各関数の入口の property 読み取り)で行うので、Cancel の遅れは、そのとき行われている呼び出し 1 回分以下(最悪 800 ms)になり、Main の Cancel の確認の 1 s(`COMPUTER_USE_NATIVE_CANCEL_ACK_TIMEOUT_MS`)に収まる(`native_stop_unconfirmed` にならない)。**ただし余裕は小さい**: 1000 − 800 = 約 200 ms が、epoch が動いてから確認を返すまでの他の処理(pipe、フレーム、応答)の分のすべてで、これは「余裕がある」とは言えない値である。CI(Windows)の実測では、transaction で縛られる呼び出しは 310〜320 ms で、最初の接触は 505〜514 ms で切れたので、1 つずつ測った 2 つの値の合計は約 830 ms で(連鎖そのものは測っていない。800 ms は公称値で、測った上限ではない)、pipe・フレーム・確認の応答に使える余裕は合わせて約 170 ms になる。負荷の高い CI や実機ではさらに小さくなりうるが、これは確かめていない。**interlock の縮退も既知の限界である**(PR-5 の設計のまま、#820 では変えない): interlock が時間切れ(kBudget)や、動いたままの worker による single-flight で判定を出せないときは『判定なし』で、interlock としては拒否しない(fail open)。その場合は V1 自身の preflight と分類が決める(未分類なら `target_unclassified` で拒否する)。見捨てた worker が UI Automation の呼び出しから戻らない間は、その後の interlock もすべて判定なしになり、その間 interlock は実質的に働かない。この値を変えるとき(timeout を伸ばすとき)は、この 1 s との関係を先に見直すこと。live seam が CI で確かめる上限は「Connection の 500 ms + 余裕 200 ms」で、これは各ケースが 1 回の呼び出し(接続か transaction のどちらか一方)で止まることを測っている。数えた 800 ms(1 回で両方の待ちが続く場合)は観測されていない安全側の見積りで、もし起きれば live seam の上限を超えて失敗する(それが警報になる)。ただしこの警報は、live seam の窓(応答しない窓、途中で止まる provider)でその連鎖が実際に起きたときにだけ鳴る。連鎖を意図して起こす fixture は無いので、800 ms の連鎖そのものは CI で検証されていない(**既知の限界**)。**経路に時間の予算は置かない**。一度、短い経路に 400 ms の予算を置いたが、深い木(Electron や Office で、要素を親へたどって 1 回ずつ呼ぶ)では、応答している app でも 400 ms を超えて `metadata_complete=false` になり、`target_unclassified` で拒否されてしまう。V1 の答えを変える退行なので外した。時間の予算は observe 全体(25 s)だけに残す。Cancel のためではなく(それは epoch の確認の役目)、Main が observe に与える 30 s が尽きる前に native が自分の理由(`observation_timeout`)で答えるためで、数千ノードの普通の app の木を 4 回(2 回の読み取りと 2 回の bindings)たどっても失敗しない広さにした。予算切れのときは、読んだノード数だけを `OutputDebugString` に出す(ラベルや文字列は出さない)。**時間切れを HRESULT だけで判断しない**。CI の Windows で、まったく答えない native provider の observe が 1.5 s かかって成功扱いになった: 5 つの property 読み取りがそれぞれ約 300 ms で時間切れになりながら、S_OK と空の値を返していた(ローカルでは UIA_E_TIMEOUT が返る)。そこで、各呼び出しの時間を測り、timeout の 30 ms 手前以上かかった呼び出しは、HRESULT が成功でも時間切れとして扱う(transaction の 300 ms に対して 270 ms、connection の 500 ms に対して 470 ms)。property の読み取りは、semantic action の中(名前、pattern の取得、toggle / expand の状態)も含めて、すべて `WindowsV1UiaRead` を通す。読み取りが完了しなかったとき(時間切れ、遅い成功)は、返ってきた pattern や BSTR は解放して捨て、副作用のある呼び出し(Invoke / SetValue / Select / Toggle / Expand / Collapse)は、直前にもう一度 `WindowsV1UiaMayCall`(時間切れと epoch)を確かめて、止まっていれば送らない(accepted は偽のまま、理由は `accessibility_timeout`)。select は名前が読めなかったら、読めなかった名前と比較せずに止まる。**フレーム全体**: preflight と dispatch の 1 フレームは外側の `WindowsV1UiaPath`(予算なし、開始時の epoch)で包み、フレームの中の短い経路(interlock の後の署名、分類、再検証)はその状態を共有する。フレームの中で最初に時間切れが起きたら残りの UIA は呼ばず、Cancel が届いたら次の呼び出しの前で止まる(key / type の 2 回目の署名や `ClassifyWindowsFocusedElement` の前も同じ)。Cancel の遅れは、そのとき行われている 1 回の呼び出し(V1 の UIA は最悪 800 ms、interlock の worker は 500 ms で打ち切り)以下で、1 s 以内になる。dispatch の共通の後処理は、`input_timeout_unconfirmed` を `input_not_confirmed` で上書きしない。**途中で止まる対象**。ClassifyWindowsUiaElement は、Self が読めなかった(`metadata_complete` が偽)ところで親をたどるのをやめる。`ClassifyNativePreflightFacts` は metadata が不完全なら secure や high impact より先に「未分類」を返すので、理由コード(`target_unclassified`)は変わらない。ClassifyWindowsPoint は、facts が不完全なら control signature を計算しない(呼び出し元は拒否して署名を読まない)。observe は、フォーカス要素の署名を分類の結果に関わらず読む(#820 より前と同じ)。生きているが未分類なだけのフォーカス(たとえば、たどる上限より深い要素)は署名を保つので、その key / type は「フォーカスが動いた」ではなく `target_unclassified` で拒否される。同じ observe の中で時間切れや Cancel が起きたあとは、経路がその読み取りを呼ばずに拒否するので、応答しない要素をもう一度待つことはない。**fail closed と理由**。時間切れの呼び出しは失敗の HRESULT を返し、各呼び出し元は既に「読めない」として扱っている: 点とフォーカスの分類は未分類のリスクを返して `RejectWindowsRisk` が `target_unclassified` で拒否し、observe の読み取り、control bindings、フォーカス要素の署名は false になって観測の失敗または `stale_target`、semantic target の検索は失敗する。V1 の判定の意味は変えていない。区別のために理由を足した: 時間切れは `accessibility_timeout`(一時的。cold な app が provider を読み込んだあとの再 observe では起きない。UIA が無い `accessibility_unavailable` と区別する)、observe 全体の予算切れは `observation_timeout`、cancel は `observation_stale`。native のエラーの code は Main の写しにそのまま渡り(observe は例外として、dispatch の失敗は accepted が偽なら `native_accessibility_timeout` の rejected として)、理由の一覧は Main には無い。**副作用のある pattern**(Invoke / SetValue / Select / Toggle / Expand)の呼び出しも同じ TransactionTimeout で切れるが、切れたときに操作が適用済みの可能性がある。timeout は変えず、その失敗が時間切れなら理由を `input_timeout_unconfirmed` にして `input_not_confirmed` と区別する。時間切れは待ちを打ち切るだけで、provider 側の処理はあとから適用されうるので、`input_timeout_unconfirmed` は「効果不明」の意味で固定する。時間切れと判定するのは時間切れ系の HRESULT(`UIA_E_TIMEOUT`、`ERROR_TIMEOUT`、`RPC_E_TIMEOUT`)だけで、それ以外の失敗は従来どおりの理由になる。どちらも accepted が真なので Main は例外として扱い、controller が効果不明として記録する(`input_not_confirmed` と同じ)。二重実行を避けるため、再試行は自動では行わず、新しい observe を見た agent の判断に任せる。副作用のある呼び出しそのものは時間を測るラッパーに入れない(遅れて成功したものを時間切れに書き換えると、適用された操作を未適用と扱うことになる)。直前の `WindowsV1UiaMayCall` から呼び出しまでの間に対象が止まる余地は構造上残る(**既知の限界**)が、その呼び出しも TransactionTimeout で切れて効果不明として返る。テスト: source 契約で、生成箇所、定数どうしの関係(Connection + Transaction が Cancel の確認の 1 s 未満、observe の予算が 30 s 未満、短い経路の予算が無いこと)、すべての呼び出しが `WindowsV1UiaCall` を通ること、各経路の止め方を固定する。live seam は、本物の host.cc をコンパイルし、自分自身を Job Object(親が終われば閉じる)に入れた子プロセスとして起動して、別プロセスの窓(応答しない窓、普通の窓、数回の問い合わせに答えてから止まる native provider の窓、応答は遅いが答える深い木)に対して V1 の 6 経路と provider の途中停止を呼び、1 回の timeout + 小さな余裕の範囲に戻って閉じる方向に失敗すること、深い木が拒否されないこと、新しいプロセスの最初の 1 回で普通の窓が読めることを確かめる。フォーカス系の 2 経路は、窓を前面にできたときだけ走り、できなければ理由を出力して skip する(flaky を避けるため、CI でも失敗にしない)。経路の境界(入れ子の経路が時間切れの状態を共有すること、Cancel が次の呼び出しの前で止めること、理由の写し方、semantic dispatch の時間切れが accepted の偽の `accessibility_timeout` になること)も確かめる。 +**V1 のレーンの UI Automation にタイムアウトを付けた(#820。S5-c-2 の計画で範囲外としていた R8)**: V1 自身がレーン(直列の処理スレッド)の上で呼ぶ UI Automation(observe の木の読み取り、点とフォーカスの分類、control bindings、semantic target の検索、再検証が使うフォーカス要素の署名)にはタイムアウトが無く、操作の対象が応答しなくなると呼び出しが戻らず、その後の observe、preflight、dispatch、Cancel がすべて進まなくなった。V1 の UIA は `CreateWindowsV1Automation` の 1 か所で作り(8 か所の `CoCreateInstance(CLSID_CUIAutomation, ...)` をまとめた)、shadow の `WindowsUiaShadowSource::Create` と共通の `CreateBoundedAutomation`(`CLSID_CUIAutomation8` + `IUIAutomation2`)で ConnectionTimeout / TransactionTimeout を設定する。`IUIAutomation2` が取れないときは従来どおりのタイムアウトの無い `CLSID_CUIAutomation` に戻り(コード上の直接生成はこのフォールバック 1 か所だけ)、回数を数えて初回だけ `OutputDebugString` に記録する。**3 つの規則**。(1) 呼び出し 1 回を縛る: ConnectionTimeout は 500 ms、TransactionTimeout は 300 ms。応答しない窓で実測すると、最初の接触(`ElementFromHandle` / `ElementFromPoint` / `GetFocusedElement`)を縛るのは ConnectionTimeout のほうで(300 に設定して 311 ms、2000 で 2001 ms。TransactionTimeout を変えても変わらない)、接続済みの要素への呼び出しを縛るのが TransactionTimeout である。接続は cold な app が provider を読み込む最初の呼び出しでもあるので 500 ms にして生きている app を「読めない」にしにくくし、接続後の呼び出しは 300 ms で足りる。1 回の呼び出しは接続の待ちのあとに transaction の待ちも続けて起こしうるので、**1 回の最悪値は 500 + 300 = 800 ms と数える**(#820 の時点では未確認だったが、PR #822 の CI(2026-10-17、Windows test shard 4/5)で live seam の `ClassifyWindowsFocusedElement` のケースが 844 ms かかり、連鎖が実際に起きることを観測した)。(2) 最初に時間切れ(`UIA_E_TIMEOUT`、`ERROR_TIMEOUT`、`RPC_E_TIMEOUT`)になった呼び出しで、その経路(`WindowsV1UiaPath`)を終わらせ、その後は UI Automation を一切呼ばない。最初の接触に答えてから止まる対象は、残りの呼び出しの数ではなく、時間切れ 1 回分で済む。(3) Cancel が届いた(`cancellation_epoch` が経路の開始時から動いた)ら、同じく経路を終わらせる。確認は**すべての呼び出しの前**(`WindowsV1UiaCall` と、各関数の入口の property 読み取り)で行うので、Cancel の遅れは、そのとき行われている呼び出し 1 回分以下(最悪 800 ms)になり、Main の Cancel の確認の 1 s(`COMPUTER_USE_NATIVE_CANCEL_ACK_TIMEOUT_MS`)に収まる(`native_stop_unconfirmed` にならない)。**ただし余裕は小さい**: 1000 − 800 = 約 200 ms が、epoch が動いてから確認を返すまでの他の処理(pipe、フレーム、応答)の分のすべてで、これは「余裕がある」とは言えない値である。CI(Windows)の実測では、transaction で縛られる呼び出しは 310〜320 ms で、最初の接触は 505〜514 ms で切れ、連鎖した 1 回は 844 ms だったので(PR #822 の CI)、pipe・フレーム・確認の応答に使える余裕は合わせて約 156 ms である。負荷の高い CI や実機ではさらに小さくなりうるが、これは確かめていない。**interlock の縮退も既知の限界である**(PR-5 の設計のまま、#820 では変えない): interlock が時間切れ(kBudget)や、動いたままの worker による single-flight で判定を出せないときは『判定なし』で、interlock としては拒否しない(fail open)。その場合は V1 自身の preflight と分類が決める(未分類なら `target_unclassified` で拒否する)。見捨てた worker が UI Automation の呼び出しから戻らない間は、その後の interlock もすべて判定なしになり、その間 interlock は実質的に働かない。この値を変えるとき(timeout を伸ばすとき)は、この 1 s との関係を先に見直すこと。live seam が CI で確かめる上限は、数えた 1 回の最悪値に余裕を足した「Connection の 500 ms + Transaction の 300 ms + 余裕 150 ms = 950 ms」で、host の定数から求め、1 s の確認より厳密に小さいことを static_assert と source 契約で固定する(#823。以前の「Connection の 500 ms + 余裕 200 ms」は、PR #822 の CI で 844 ms の連鎖に当たって失敗した)。これは各ケースが 1 回の呼び出しで止まることを測っており、2 回目の遅い呼び出しをすれば上限を超えて失敗する。連鎖を意図して起こす fixture は無く、連鎖が起きるかどうかは負荷しだいである。**経路に時間の予算は置かない**。一度、短い経路に 400 ms の予算を置いたが、深い木(Electron や Office で、要素を親へたどって 1 回ずつ呼ぶ)では、応答している app でも 400 ms を超えて `metadata_complete=false` になり、`target_unclassified` で拒否されてしまう。V1 の答えを変える退行なので外した。時間の予算は observe 全体(25 s)だけに残す。Cancel のためではなく(それは epoch の確認の役目)、Main が observe に与える 30 s が尽きる前に native が自分の理由(`observation_timeout`)で答えるためで、数千ノードの普通の app の木を 4 回(2 回の読み取りと 2 回の bindings)たどっても失敗しない広さにした。予算切れのときは、読んだノード数だけを `OutputDebugString` に出す(ラベルや文字列は出さない)。**時間切れを HRESULT だけで判断しない**。CI の Windows で、まったく答えない native provider の observe が 1.5 s かかって成功扱いになった: 5 つの property 読み取りがそれぞれ約 300 ms で時間切れになりながら、S_OK と空の値を返していた(ローカルでは UIA_E_TIMEOUT が返る)。そこで、各呼び出しの時間を測り、timeout の 30 ms 手前以上かかった呼び出しは、**出力が空なら**、HRESULT が成功でも時間切れとして扱う(transaction の 300 ms に対して 270 ms、connection の 500 ms に対して 470 ms)。**遅くても値を返した呼び出しは成功とする(#823)**。#820 の時点では遅い成功を一律に時間切れとしていたが、応答している app でも負荷が高いと 1 回の呼び出しがこの値を超えることがあり(PR #821 の CI で、深い木の live seam が 1 回 `target_unclassified` で拒否された)、深い木の live app を未分類で拒否する退行と同じ症状になった。時間切れの呼び出しが残すのは空の値なので、空かどうかで区別する。空の形は型ごとに 1 つで(`WindowsV1UiaEmpty`)、要素・pattern・walker の移動の結果・SAFEARRAY は null、BSTR は null か長さ 0、RECT はすべて 0、値の型(BOOL、int、enum)は 0 である。各呼び出し元は、その呼び出しが書く出力そのものをラッパーに渡す(source 契約で、すべての UIA 呼び出しがラッパーを通ることと、渡す出力がその呼び出しの書く変数であることを数える)。値の型には「空」が無いので 0 を空とする: 0 が正しい答えでもありうるもの(名前の無い要素、子の無い要素、最後の兄弟、password ではない control の FALSE、ToggleState_Off、ExpandCollapseState_Collapsed)は、遅ければ従来どおり時間切れとして拒否する(fail closed。特に IsPassword の遅い FALSE を「password ではない」と読まない)。速い 0 は答えである。**最初の接触(`ElementFromHandle` / `ElementFromPoint` / `GetFocusedElement`)は例外で、遅ければ返した要素にかかわらず時間切れとする**(#820 の規則のまま)。UI Automation は、app 自身の provider が切れても窓の HWND から要素を作って返すので、要素があることは app が答えた証拠にならない(ローカルの live seam で、答えなくなった provider の窓への `ElementFromHandle` が 1026 ms かかって S_OK と要素を返した。これを成功とすると、その後の呼び出しが続いて observe が 2.5 s かかった)。何も書かずに切れた呼び出しは呼び出し前の初期値を残すが、初期値は空の値と同じで、例外の toggle / expand の状態(初期値は Indeterminate / LeafNode)は、呼び出し元がその値を `semantic_state_unavailable` で拒否する。遅い成功の値が空でないのに実は時間切れだった、という形は観測されていない(観測されれば、この区別は崩れる)。live seam は、窓を使わずに閾値を少し超えて眠る呼び出しで、値のある遅い成功(名前、control type、要素への移動、TRUE)が成功で経路が続くこと、空の遅い成功(空の BSTR、null の要素、FALSE)と、要素を返した遅い最初の接触が時間切れで経路を終わらせることを確かめる。property の読み取りは、semantic action の中(名前、pattern の取得、toggle / expand の状態)も含めて、すべて `WindowsV1UiaRead` を通す。読み取りが完了しなかったとき(時間切れ、空の値の遅い成功)は、返ってきた pattern や BSTR は解放して捨て、副作用のある呼び出し(Invoke / SetValue / Select / Toggle / Expand / Collapse)は、直前にもう一度 `WindowsV1UiaMayCall`(時間切れと epoch)を確かめて、止まっていれば送らない(accepted は偽のまま、理由は `accessibility_timeout`)。select は名前が読めなかったら、読めなかった名前と比較せずに止まる。**フレーム全体**: preflight と dispatch の 1 フレームは外側の `WindowsV1UiaPath`(予算なし、開始時の epoch)で包み、フレームの中の短い経路(interlock の後の署名、分類、再検証)はその状態を共有する。フレームの中で最初に時間切れが起きたら残りの UIA は呼ばず、Cancel が届いたら次の呼び出しの前で止まる(key / type の 2 回目の署名や `ClassifyWindowsFocusedElement` の前も同じ)。Cancel の遅れは、そのとき行われている 1 回の呼び出し(V1 の UIA は最悪 800 ms、interlock の worker は 500 ms で打ち切り)以下で、1 s 以内になる。dispatch の共通の後処理は、`input_timeout_unconfirmed` を `input_not_confirmed` で上書きしない。**途中で止まる対象**。ClassifyWindowsUiaElement は、Self が読めなかった(`metadata_complete` が偽)ところで親をたどるのをやめる。`ClassifyNativePreflightFacts` は metadata が不完全なら secure や high impact より先に「未分類」を返すので、理由コード(`target_unclassified`)は変わらない。ClassifyWindowsPoint は、facts が不完全なら control signature を計算しない(呼び出し元は拒否して署名を読まない)。observe は、フォーカス要素の署名を分類の結果に関わらず読む(#820 より前と同じ)。生きているが未分類なだけのフォーカス(たとえば、たどる上限より深い要素)は署名を保つので、その key / type は「フォーカスが動いた」ではなく `target_unclassified` で拒否される。同じ observe の中で時間切れや Cancel が起きたあとは、経路がその読み取りを呼ばずに拒否するので、応答しない要素をもう一度待つことはない。**fail closed と理由**。時間切れの呼び出しは失敗の HRESULT を返し、各呼び出し元は既に「読めない」として扱っている: 点とフォーカスの分類は未分類のリスクを返して `RejectWindowsRisk` が `target_unclassified` で拒否し、observe の読み取り、control bindings、フォーカス要素の署名は false になって観測の失敗または `stale_target`、semantic target の検索は失敗する。V1 の判定の意味は変えていない。区別のために理由を足した: 時間切れは `accessibility_timeout`(一時的。cold な app が provider を読み込んだあとの再 observe では起きない。UIA が無い `accessibility_unavailable` と区別する)、observe 全体の予算切れは `observation_timeout`、cancel は `observation_stale`。native のエラーの code は Main の写しにそのまま渡り(observe は例外として、dispatch の失敗は accepted が偽なら `native_accessibility_timeout` の rejected として)、理由の一覧は Main には無い。**副作用のある pattern**(Invoke / SetValue / Select / Toggle / Expand)の呼び出しも同じ TransactionTimeout で切れるが、切れたときに操作が適用済みの可能性がある。timeout は変えず、その失敗が時間切れなら理由を `input_timeout_unconfirmed` にして `input_not_confirmed` と区別する。時間切れは待ちを打ち切るだけで、provider 側の処理はあとから適用されうるので、`input_timeout_unconfirmed` は「効果不明」の意味で固定する。時間切れと判定するのは時間切れ系の HRESULT(`UIA_E_TIMEOUT`、`ERROR_TIMEOUT`、`RPC_E_TIMEOUT`)だけで、それ以外の失敗は従来どおりの理由になる。どちらも accepted が真なので Main は例外として扱い、controller が効果不明として記録する(`input_not_confirmed` と同じ)。二重実行を避けるため、再試行は自動では行わず、新しい observe を見た agent の判断に任せる。副作用のある呼び出しそのものは時間を測るラッパーに入れない(遅れて成功したものを時間切れに書き換えると、適用された操作を未適用と扱うことになる)。直前の `WindowsV1UiaMayCall` から呼び出しまでの間に対象が止まる余地は構造上残る(**既知の限界**)が、その呼び出しも TransactionTimeout で切れて効果不明として返る。テスト: source 契約で、生成箇所、定数どうしの関係(Connection + Transaction が Cancel の確認の 1 s 未満、observe の予算が 30 s 未満、短い経路の予算が無いこと)、すべての呼び出しが `WindowsV1UiaCall` を通ること、各経路の止め方を固定する。live seam は、本物の host.cc をコンパイルし、自分自身を Job Object(親が終われば閉じる)に入れた子プロセスとして起動して、別プロセスの窓(応答しない窓、普通の窓、数回の問い合わせに答えてから止まる native provider の窓、応答は遅いが答える深い木)に対して V1 の 6 経路と provider の途中停止を呼び、1 回の呼び出しの最悪値 + 余裕(950 ms)の範囲に戻って閉じる方向に失敗すること、深い木が拒否されないこと、新しいプロセスの最初の 1 回で普通の窓が読めることを確かめる。フォーカス系の 2 経路は、窓を前面にできたときだけ走り、できなければ理由を出力して skip する(flaky を避けるため、CI でも失敗にしない)。経路の境界(入れ子の経路が時間切れの状態を共有すること、Cancel が次の呼び出しの前で止めること、理由の写し方、semantic dispatch の時間切れが accepted の偽の `accessibility_timeout` になること)も確かめる。 **S5-c-2 PR-5(拒否の有効化。2026-10-17)**: Windows の preflight と dispatch から同期の入口を呼び、S4-1a の受領書で拒否する。S5-c の (1)〜(6) は次のとおり解決済みである。 (1) UIA のタイムアウトは同期の入口で 250 ms、レーンの保持の上限は 500 ms(最悪の見積りは PR-1 の節のとおり。preflight と dispatch の 2 回で 1 操作あたり最大約 1 s の遅れ)。(2) Explorer の項目は PR-3 のとおりフォルダのパスと列挙で実パスを取る(lookup は 150 ms の待ち)。実パスが取れたファイル項目(`run.bat` の実行トリガ、`invoice.pdf` の承認が閉じた件)は、この PR で拒否される。取れない・曖昧・仮想フォルダ・枠の使用中は判定なし(kIncomplete)のまま。(3) lookup の枠は shadow と同期評価で分けた。(4) MTA は評価ごとのワーカーで実現し、主スレッドは picker と WinRT の捕捉のために STA に残す。(5) 座標写像は `WindowsActionPoint` の 1 つ。(6) 開始前の取消しは UIA を作らずに kCanceled。