Preserve native K1 error causes and distinguish Wi-Fi actions
This commit is contained in:
@@ -11,7 +11,7 @@ import sys
|
|||||||
|
|
||||||
|
|
||||||
ROOT = Path(__file__).resolve().parents[1]
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
VERSION = "0.8.5"
|
VERSION = "0.8.6"
|
||||||
sys.path.insert(0, str(ROOT.parents[1] / "scripts/packaging"))
|
sys.path.insert(0, str(ROOT.parents[1] / "scripts/packaging"))
|
||||||
from debian import package
|
from debian import package
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
# Onboard K1 causal diagnostics R8
|
||||||
|
|
||||||
|
The owner screenshot after R7 corresponds to a new network.provision operation,
|
||||||
|
not connection.verify. The operation failed at connect-failed in approximately
|
||||||
|
68 ms with BleakError, side_effect_status=none and network_not_applied. It did
|
||||||
|
not cross the Wi-Fi dispatch boundary. The service remained active without a
|
||||||
|
restart. This is distinct from the Linux route-probe TypeError corrected in R7.
|
||||||
|
The Bluetooth failure origin remains unproven; a missing BlueZ device object
|
||||||
|
is only a hypothesis and is not grounds for an automatic rescan or write retry.
|
||||||
|
|
||||||
|
The existing journal logger only recorded PluginExecutionError and the facade
|
||||||
|
wrapper stack. The facade retains the native exception as __cause__, but the
|
||||||
|
logger did not follow it. The new bounded causal logger records exception class
|
||||||
|
and basename/function/line for up to six causes and ten frames per cause. It
|
||||||
|
respects suppressed context and terminates cycles. It never renders exception
|
||||||
|
messages, source lines, frame locals or payloads. Both journalled and outer
|
||||||
|
HTTP failure paths use it. Error responses and device admission are unchanged.
|
||||||
|
|
||||||
|
A regression now passes through the actual NodeBridge and plugin facade with
|
||||||
|
only the service endpoint replaced. It verifies that the native BleakError
|
||||||
|
location survives the real PluginExecutionError wrapper, exactly one connect
|
||||||
|
is followed by state observation, and generated secret material is absent from
|
||||||
|
logs and the response. A second test covers cyclic and suppressed chains.
|
||||||
|
The earlier test substituted NodeBridge.invoke and therefore never exercised
|
||||||
|
the wrapper that obscured the installed failure.
|
||||||
|
|
||||||
|
The existing wireless modal also distinguishes its two actions more clearly.
|
||||||
|
Check K1 state appears before the network fields; the provisioning action is
|
||||||
|
now labelled Apply Wi-Fi and check. No new controls, permissions, retries,
|
||||||
|
state machine, profiles or host-network changes are introduced. Final Connect
|
||||||
|
continues to require current device/session proof.
|
||||||
|
|
||||||
|
Validation: 20 NodeBridge/package lifecycle tests, 23 focused frontend and
|
||||||
|
architecture checks, 794 full Core frontend tests, Core TypeScript/production
|
||||||
|
build and scoped Ruff passed. Node 0.8.6 and optional K1 0.1.5 are reserved for
|
||||||
|
this source. Package, installation and owner UI results will be recorded after
|
||||||
|
they are observed. This increment improves diagnosis and action clarity; it
|
||||||
|
does not claim to fix the physical Bluetooth failure.
|
||||||
|
|
||||||
|
The owner screenshot and exact operation result are preserved with UTC and
|
||||||
|
monotonic collection timestamps and SHA-256 hashes under private/acceptance/
|
||||||
|
k1-node085-20260907-bluetooth-1355. Browser cache clearing for that attempt has
|
||||||
|
not been confirmed. No hardware commands were issued by the agent. The next
|
||||||
|
physical check remains through the owner UI after clearing browser cache.
|
||||||
@@ -133,10 +133,14 @@ export function DeviceEnrollmentWindow({transport,onChange,onClose,renderWindow}
|
|||||||
{bluetoothFailure&&<div data-enrollment-reveal="result" role="status"><SettingsCard align="center" title={notice}/></div>}
|
{bluetoothFailure&&<div data-enrollment-reveal="result" role="status"><SettingsCard align="center" title={notice}/></div>}
|
||||||
</SettingsCard>
|
</SettingsCard>
|
||||||
{selected&&<div data-enrollment-reveal="wifi"><SettingsCard eyebrow="Шаг 02" title="Wi-Fi"
|
{selected&&<div data-enrollment-reveal="wifi"><SettingsCard eyebrow="Шаг 02" title="Wi-Fi"
|
||||||
description="Укажите сеть для K1. Проверка передаст настройки сканеру и проверит связь с БК." actions={!confirmed&&
|
description="Проверьте связь с K1 в уже настроенной сети или укажите новую сеть Wi-Fi." actions={!confirmed&&
|
||||||
<Button disabled={pending} aria-busy={busy==='networks'} icon={busy==='networks'?<ActivityIndicator size="compact"/>:undefined}
|
<Button disabled={pending} aria-busy={busy==='networks'} icon={busy==='networks'?<ActivityIndicator size="compact"/>:undefined}
|
||||||
onClick={()=>void run('networks')}>{busy==='networks'?'Ищем сети':'Найти сети'}</Button>}>
|
onClick={()=>void run('networks')}>{busy==='networks'?'Ищем сети':'Найти сети'}</Button>}>
|
||||||
{!confirmed&&<>
|
{!confirmed&&<>
|
||||||
|
{enrollmentAllowed(state,'verify')&&
|
||||||
|
<Button disabled={pending} aria-busy={busy==='verify'}
|
||||||
|
icon={busy==='verify'?<ActivityIndicator size="compact"/>:undefined}
|
||||||
|
onClick={()=>void run('verify')}>{busy==='verify'?'Проверяем состояние':'Проверить состояние K1'}</Button>}
|
||||||
<div data-enrollment-reveal="networks">
|
<div data-enrollment-reveal="networks">
|
||||||
{!!networks.length&&<Select label="Сеть Wi-Fi" value={network}
|
{!!networks.length&&<Select label="Сеть Wi-Fi" value={network}
|
||||||
options={[{value:'manual',label:'Ввести название сети'},...networks.map((value,index)=>({value:String(index),label:value.ssid,description:`Сигнал ${value.signal}% · ${value.security||'Без защиты'}`}))]}
|
options={[{value:'manual',label:'Ввести название сети'},...networks.map((value,index)=>({value:String(index),label:value.ssid,description:`Сигнал ${value.signal}% · ${value.security||'Без защиты'}`}))]}
|
||||||
@@ -146,11 +150,7 @@ export function DeviceEnrollmentWindow({transport,onChange,onClose,renderWindow}
|
|||||||
<TextField label="Пароль Wi-Fi" type="password" value={password} onChange={event=>setPassword(event.target.value)} disabled={pending} autoComplete="new-password"/>
|
<TextField label="Пароль Wi-Fi" type="password" value={password} onChange={event=>setPassword(event.target.value)} disabled={pending} autoComplete="new-password"/>
|
||||||
<Button disabled={pending||!enrollmentAllowed(state,'connect')||!bridgeFormValid(ssid,password)} aria-busy={busy==='connect'}
|
<Button disabled={pending||!enrollmentAllowed(state,'connect')||!bridgeFormValid(ssid,password)} aria-busy={busy==='connect'}
|
||||||
icon={busy==='connect'?<ActivityIndicator size="compact"/>:undefined}
|
icon={busy==='connect'?<ActivityIndicator size="compact"/>:undefined}
|
||||||
onClick={()=>void run('connect')}>{busy==='connect'?'Проверяем подключение':'Проверить подключение'}</Button>
|
onClick={()=>void run('connect')}>{busy==='connect'?'Применяем Wi-Fi и проверяем':'Применить Wi-Fi и проверить'}</Button>
|
||||||
{enrollmentAllowed(state,'verify')&&
|
|
||||||
<Button disabled={pending} aria-busy={busy==='verify'}
|
|
||||||
icon={busy==='verify'?<ActivityIndicator size="compact"/>:undefined}
|
|
||||||
onClick={()=>void run('verify')}>{busy==='verify'?'Проверяем состояние':'Проверить состояние K1'}</Button>}
|
|
||||||
</>}
|
</>}
|
||||||
{notice&&!bluetoothFailure&&<div data-enrollment-reveal="result" role="status"><SettingsCard align="center" title={notice}/></div>}
|
{notice&&!bluetoothFailure&&<div data-enrollment-reveal="result" role="status"><SettingsCard align="center" title={notice}/></div>}
|
||||||
</SettingsCard></div>}
|
</SettingsCard></div>}
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ from credential_install import PROFILE_ID, validate # noqa: E402
|
|||||||
from debian import package # noqa: E402
|
from debian import package # noqa: E402
|
||||||
from runtime_payload import files as runtime_files # noqa: E402
|
from runtime_payload import files as runtime_files # noqa: E402
|
||||||
|
|
||||||
VERSION = "0.1.4"
|
VERSION = "0.1.5"
|
||||||
RESOURCES = (
|
RESOURCES = (
|
||||||
"plugins/xgrids-k1/profile_loader.py",
|
"plugins/xgrids-k1/profile_loader.py",
|
||||||
"plugins/xgrids-k1/plugin.manifest.json",
|
"plugins/xgrids-k1/plugin.manifest.json",
|
||||||
|
|||||||
@@ -37,6 +37,29 @@ ATTESTATION = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def failure_locations(error: BaseException) -> str:
|
||||||
|
"""Bounded causal stack, excluding messages, source lines and frame locals.
|
||||||
|
|
||||||
|
The facade deliberately wraps native transport errors. Logging only the
|
||||||
|
outer stack loses the actual OS failure boundary on journalled responses.
|
||||||
|
"""
|
||||||
|
chain: list[str] = []
|
||||||
|
seen: set[int] = set()
|
||||||
|
current: BaseException | None = error
|
||||||
|
while current is not None and id(current) not in seen and len(chain) < 6:
|
||||||
|
seen.add(id(current))
|
||||||
|
frames = list(traceback.walk_tb(current.__traceback__))[-10:]
|
||||||
|
locations = ";".join(
|
||||||
|
f"{Path(frame.f_code.co_filename).name}:{frame.f_code.co_name}:{line}"
|
||||||
|
for frame, line in frames
|
||||||
|
)
|
||||||
|
chain.append(f"{type(current).__name__}[{locations}]")
|
||||||
|
current = current.__cause__ or (
|
||||||
|
None if current.__suppress_context__ else current.__context__
|
||||||
|
)
|
||||||
|
return " <- ".join(chain)
|
||||||
|
|
||||||
|
|
||||||
class NodeEnrollmentRejected(ValueError):
|
class NodeEnrollmentRejected(ValueError):
|
||||||
"""A host admission failure proven to precede any device invocation."""
|
"""A host admission failure proven to precede any device invocation."""
|
||||||
|
|
||||||
@@ -152,14 +175,11 @@ class NodeBridge:
|
|||||||
# results, so the HTTP exception logger never sees them. Record
|
# results, so the HTTP exception logger never sees them. Record
|
||||||
# only source locations here; exception text may contain a frame.
|
# only source locations here; exception text may contain a frame.
|
||||||
logging.getLogger(__name__).warning(
|
logging.getLogger(__name__).warning(
|
||||||
"K1 journalled invocation failed: action=%s operation=%s exception=%s locations=%s",
|
"K1 journalled invocation failed: action=%s operation=%s exception=%s chain=%s",
|
||||||
action,
|
action,
|
||||||
identifier,
|
identifier,
|
||||||
type(error).__name__,
|
type(error).__name__,
|
||||||
";".join(
|
failure_locations(error),
|
||||||
f"{Path(frame.filename).name}:{frame.name}:{frame.lineno}"
|
|
||||||
for frame in traceback.extract_tb(error.__traceback__)[-10:]
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
# Read the exact result after a failure; never repeat an invocation
|
# Read the exact result after a failure; never repeat an invocation
|
||||||
# or interpret an exception as evidence that a command was unsent.
|
# or interpret an exception as evidence that a command was unsent.
|
||||||
@@ -346,15 +366,12 @@ def create_app(repository_root: Path):
|
|||||||
# text, source lines, local variables, payloads or credentials.
|
# text, source lines, local variables, payloads or credentials.
|
||||||
action = body.get("action") if isinstance(body, dict) else None
|
action = body.get("action") if isinstance(body, dict) else None
|
||||||
logging.getLogger(__name__).warning(
|
logging.getLogger(__name__).warning(
|
||||||
"K1 operation failed: action=%s exception=%s locations=%s",
|
"K1 operation failed: action=%s exception=%s chain=%s",
|
||||||
action
|
action
|
||||||
if isinstance(action, str) and action in {"scan", "networks", "connect", "verify"}
|
if isinstance(action, str) and action in {"scan", "networks", "connect", "verify"}
|
||||||
else "invalid",
|
else "invalid",
|
||||||
type(error).__name__,
|
type(error).__name__,
|
||||||
";".join(
|
failure_locations(error),
|
||||||
f"{Path(frame.filename).name}:{frame.name}:{frame.lineno}"
|
|
||||||
for frame in traceback.extract_tb(error.__traceback__)[-8:]
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -97,6 +97,69 @@ def test_journalled_failure_logs_source_without_secret_and_does_not_repeat(caplo
|
|||||||
assert secret not in caplog.text
|
assert secret not in caplog.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_real_facade_preserves_native_failure_location_in_journalled_log(caplog):
|
||||||
|
from bleak.exc import BleakError
|
||||||
|
|
||||||
|
secret = secrets.token_hex(20)
|
||||||
|
calls = []
|
||||||
|
|
||||||
|
class Service:
|
||||||
|
current = state()
|
||||||
|
|
||||||
|
def bind_runtime_event_loop(self, _loop):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def require_snapshot_runtime_id(self, expected):
|
||||||
|
assert expected == self.current["snapshot_runtime_id"]
|
||||||
|
|
||||||
|
def state(self):
|
||||||
|
calls.append("state")
|
||||||
|
return copy.deepcopy(self.current)
|
||||||
|
|
||||||
|
async def connect(self, request):
|
||||||
|
calls.append("connect")
|
||||||
|
self.current["operations"] = [{
|
||||||
|
"operation_id": request.operation_id, "status": "failed",
|
||||||
|
"error": {"code": "BleakError"},
|
||||||
|
}]
|
||||||
|
raise BleakError(secret)
|
||||||
|
|
||||||
|
async def run():
|
||||||
|
device = NodeBridge(Path.cwd(), service=Service())
|
||||||
|
command = {
|
||||||
|
"operation_id": "op_" + "c" * 32, "runtime_id": "runtime-one",
|
||||||
|
"deadline_at": (datetime.now(UTC) + timedelta(seconds=60)).isoformat(),
|
||||||
|
"action": "connect", "parameters": {
|
||||||
|
"device_id": "AA:BB:CC:DD:EE:FF", "discovery_generation": 1,
|
||||||
|
"mode_revision": 0, "ssid": "test-network", "password": secret,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
result = await device.deliver(command)
|
||||||
|
assert result["command_result"]["status"] == "failed"
|
||||||
|
assert calls == ["state", "connect", "state"]
|
||||||
|
assert "password" not in command["parameters"]
|
||||||
|
assert secret not in json.dumps(result)
|
||||||
|
|
||||||
|
asyncio.run(run())
|
||||||
|
assert "PluginExecutionError[" in caplog.text
|
||||||
|
assert "BleakError[" in caplog.text
|
||||||
|
assert "test_node_k1_bridge.py:connect:" in caplog.text
|
||||||
|
assert secret not in caplog.text
|
||||||
|
assert "test-network" not in caplog.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_failure_locations_respects_suppression_and_bounds_cyclic_chains():
|
||||||
|
from k1link.device_plugins.xgrids_k1.node_bridge import failure_locations
|
||||||
|
|
||||||
|
first = RuntimeError(secrets.token_hex(20))
|
||||||
|
second = ValueError(secrets.token_hex(20))
|
||||||
|
first.__context__ = second
|
||||||
|
second.__cause__ = first
|
||||||
|
assert failure_locations(first) == "RuntimeError[] <- ValueError[]"
|
||||||
|
first.__suppress_context__ = True
|
||||||
|
assert failure_locations(first) == "RuntimeError[]"
|
||||||
|
|
||||||
|
|
||||||
def test_node_scan_reaches_service_through_real_facade_with_runtime_fence():
|
def test_node_scan_reaches_service_through_real_facade_with_runtime_fence():
|
||||||
"""Exercise the actual admission boundary, replacing only the BLE service."""
|
"""Exercise the actual admission boundary, replacing only the BLE service."""
|
||||||
from k1link.device_plugins.xgrids_k1.facade import SnapshotRuntimeConflict
|
from k1link.device_plugins.xgrids_k1.facade import SnapshotRuntimeConflict
|
||||||
|
|||||||
Reference in New Issue
Block a user