Skip to content

Commit 006142d

Browse files
Merge pull request #53 from BitHighlander/fix/ci-hang-and-false-green
fix(ci): a crashed emulator fails in seconds instead of hanging 30 minutes
2 parents f9849f6 + 594e366 commit 006142d

5 files changed

Lines changed: 693 additions & 147 deletions

File tree

.github/workflows/ci.yml

Lines changed: 124 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,12 @@ on:
1717
pull_request:
1818
branches: [master, develop, reconcile/upstream-sync]
1919

20+
# One run per ref: a new push supersedes the old instead of both burning a
21+
# runner to completion.
22+
concurrency:
23+
group: ci-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
24+
cancel-in-progress: true
25+
2026
jobs:
2127
# ═══════════════════════════════════════════════════════════
2228
# STAGE 1: GATE
@@ -62,26 +68,74 @@ jobs:
6268
integration:
6369
needs: [lint]
6470
runs-on: ubuntu-latest
65-
timeout-minutes: 30
71+
timeout-minutes: 15
6672

67-
services:
68-
kkemu:
69-
image: kktech/kkemu:latest
70-
ports:
71-
- 11044:11044/udp
72-
- 11045:11045/udp
73-
- 5000:5000
73+
# NO published emulator image. This job BUILDS one from current firmware.
74+
#
75+
# It used to pull kktech/kkemu:latest -- a floating tag whose image was
76+
# five months and six minor versions stale. That single fact caused every
77+
# symptom we chased: 80 tests gating on requires_firmware("7.15.0") skipped
78+
# silently, and one unskipped test drove a ctime() path that segfaults on
79+
# the old image and does not exist in current firmware.
80+
#
81+
# Publishing a fresher image would only reset that clock. Building from
82+
# source removes the class: the emulator under test is, by construction,
83+
# the firmware the tests were written against.
7484

7585
steps:
7686
- uses: actions/checkout@v4
7787
with:
7888
submodules: recursive
89+
path: python-keepkey
90+
91+
# python-keepkey is a SUBMODULE of the firmware repo, so the firmware is
92+
# where the emulator lives. alpha is the fork's integration branch.
93+
- name: Checkout firmware
94+
uses: actions/checkout@v4
95+
with:
96+
repository: BitHighlander/keepkey-firmware
97+
ref: alpha
98+
path: keepkey-firmware
99+
100+
# NOT `submodules: recursive`. trezor-firmware carries a micropython
101+
# vendor tree whose lib/lwip lives on git.savannah.gnu.org, which serves
102+
# dumb HTTP and cannot do the shallow clone actions/checkout requests --
103+
# it fails the whole job. The firmware repo's own CI inits exactly these
104+
# paths, non-recursively, for the same reason.
105+
- name: Init the submodules the emulator build needs
106+
working-directory: keepkey-firmware
107+
run: |
108+
git submodule update --init --depth 1 deps/crypto/trezor-firmware
109+
git submodule update --init --depth 1 deps/device-protocol
110+
git submodule update --init --depth 1 deps/googletest
111+
git submodule update --init --depth 1 deps/qrenc/QR-Code-generator
112+
git submodule update --init --depth 1 deps/sca-hardening/SecAESSTM32
113+
114+
# Test THIS checkout of python-keepkey, not the one the firmware pins.
115+
- name: Overlay this python-keepkey onto the firmware tree
116+
run: |
117+
rm -rf keepkey-firmware/deps/python-keepkey
118+
cp -a python-keepkey keepkey-firmware/deps/python-keepkey
119+
120+
- name: Build the emulator
121+
timeout-minutes: 20
122+
working-directory: keepkey-firmware
123+
run: |
124+
docker build -t kkemu-ci -f scripts/emulator/Dockerfile .
125+
126+
- name: Start the emulator
127+
run: |
128+
docker run -d --name kkemu \
129+
-p 11044:11044/udp -p 11045:11045/udp -p 5000:5000 kkemu-ci
130+
sleep 3
131+
docker logs kkemu | head -5
79132
80133
- uses: actions/setup-python@v5
81134
with:
82135
python-version: '3.11'
83136

84137
- name: Install dependencies
138+
working-directory: python-keepkey
85139
run: |
86140
pip install --upgrade pip
87141
pip install "protobuf>=3.20,<4"
@@ -101,20 +155,67 @@ jobs:
101155
sleep 1
102156
done
103157
158+
# "The emulator answered a ping" is not "the emulator is the right
159+
# firmware". CI ran a 7.16-era suite against a 7.10.0 image for five
160+
# months: 80 tests gate on requires_firmware("7.15.0") and silently
161+
# SKIPPED, while one unskipped test drove a code path that segfaults in
162+
# 7.10.0 and is already fixed in 7.15 -- which reads as a product failure
163+
# but is only a stale image. A floating tag cannot tell you that. This
164+
# can, and it fails closed.
165+
- name: Assert the emulator is not older than the suite
166+
timeout-minutes: 2
167+
env:
168+
KK_TRANSPORT_MAIN: "127.0.0.1:11044"
169+
KK_TRANSPORT_DEBUG: "127.0.0.1:11045"
170+
KK_MIN_FW: "7.15.0"
171+
KK_UDP_TIMEOUT: "20"
172+
working-directory: keepkey-firmware/deps/python-keepkey/tests
173+
run: |
174+
python - <<'PY'
175+
import os, sys
176+
sys.path.insert(0, '..')
177+
import config
178+
from keepkeylib.client import KeepKeyDebuglinkClient
179+
c = KeepKeyDebuglinkClient(config.TRANSPORT(*config.TRANSPORT_ARGS,
180+
**config.TRANSPORT_KWARGS))
181+
c.set_debuglink(config.DEBUG_TRANSPORT(*config.DEBUG_TRANSPORT_ARGS,
182+
**config.DEBUG_TRANSPORT_KWARGS))
183+
c.init_device()
184+
f = c.features
185+
got = (f.major_version, f.minor_version, f.patch_version)
186+
floor = tuple(int(x) for x in os.environ['KK_MIN_FW'].split('.'))
187+
print('emulator firmware %d.%d.%d, floor %s' %
188+
(got + (os.environ['KK_MIN_FW'],)))
189+
if got < floor:
190+
sys.exit('FATAL: the emulator image predates the tests that run '
191+
'against it. Republish kktech/kkemu from current '
192+
'firmware and pin the new digest above.')
193+
PY
194+
195+
# Step-level timeout, deliberately: a JOB-level timeout ends the job as
196+
# "cancelled", which reads as an infra blip. A step timeout is a FAILURE.
104197
- name: Run integration tests
198+
timeout-minutes: 8
105199
env:
106200
KK_TRANSPORT_MAIN: "127.0.0.1:11044"
107201
KK_TRANSPORT_DEBUG: "127.0.0.1:11045"
108-
PYTHONPATH: "${{ github.workspace }}/keepkeylib:${{ github.workspace }}"
202+
PYTHONPATH: "${{ github.workspace }}/keepkey-firmware/deps/python-keepkey"
203+
# A crashed emulator now raises instead of blocking in recv() forever.
204+
KK_UDP_TIMEOUT: "45"
109205
run: |
110-
cd tests
206+
# From the OVERLAID copy, not the standalone checkout: the
207+
# storage-version-gate tests assert against lib/firmware/storage.c,
208+
# which they find by walking UP. Run them as a sibling of the
209+
# firmware and they resolve; run them standalone and they fail
210+
# claiming the sources are missing.
211+
cd keepkey-firmware/deps/python-keepkey/tests
111212
pytest -v --junitxml=junit.xml 2>&1 | tee pytest-output.txt
112213
echo "${PIPESTATUS[0]}" > status
113214
114215
- name: Test summary
115216
if: always()
116217
run: |
117-
XML="tests/junit.xml"
218+
XML="keepkey-firmware/deps/python-keepkey/tests/junit.xml"
118219
echo "## 🔑 KeepKey python-keepkey — Integration Tests" >> "$GITHUB_STEP_SUMMARY"
119220
echo "" >> "$GITHUB_STEP_SUMMARY"
120221
@@ -161,15 +262,23 @@ jobs:
161262
echo "---" >> "$GITHUB_STEP_SUMMARY"
162263
echo "*KeepKey python-keepkey CI*" >> "$GITHUB_STEP_SUMMARY"
163264
164-
- name: Upload test results
265+
# NO check_name. With one, this action publishes a SEPARATE check run
266+
# via the Checks API, and its require_tests default of 'false' means an
267+
# absent junit.xml -- which is exactly what a killed pytest leaves behind
268+
# -- reports conclusion:success with zero duration. That green check sat
269+
# on top of a job timing out at 30 minutes for at least six merges.
270+
# annotate_only keeps the inline annotations without minting a check.
271+
- name: Annotate test results
165272
uses: mikepenz/action-junit-report@v4
166273
if: always()
167274
with:
168-
report_paths: tests/junit.xml
169-
check_name: Integration Tests
275+
report_paths: keepkey-firmware/deps/python-keepkey/tests/junit.xml
276+
annotate_only: true
277+
require_tests: true
278+
fail_on_failure: true
170279

171280
- name: Fail on test failure
172281
if: always()
173282
run: |
174-
STATUS=$(cat tests/status 2>/dev/null || echo "1")
283+
STATUS=$(cat keepkey-firmware/deps/python-keepkey/tests/status 2>/dev/null || echo "1")
175284
[ "$STATUS" = "0" ] || exit 1

keepkeylib/transport_udp.py

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,23 @@
22

33
'''SocketTransport implements TCP socket interface for Transport.'''
44

5+
import os
56
import socket
67
from select import select
78
from .transport import Transport
89

10+
# A dead emulator must surface as an ERROR, not as an infinite wait.
11+
#
12+
# The socket had no timeout, so when the emulator segfaulted mid-suite,
13+
# recv() blocked in a syscall until something outside killed the process --
14+
# in CI that was a 30-minute job timeout reported as "cancelled", which reads
15+
# as an infrastructure blip rather than the device crash it actually was. It
16+
# hid a real segfault for at least six merges.
17+
#
18+
# Generous by default because a confirm screen legitimately waits on a human;
19+
# override for unattended runs with KK_UDP_TIMEOUT (seconds, 0 disables).
20+
DEFAULT_TIMEOUT = float(os.environ.get('KK_UDP_TIMEOUT', '60'))
21+
922
class FakeRead(object):
1023
# Let's pretend we have a file-like interface
1124
def __init__(self, func):
@@ -31,6 +44,8 @@ def __init__(self, device, *args, **kwargs):
3144
def _open(self):
3245
self.socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
3346
self.socket.connect(self.device)
47+
if DEFAULT_TIMEOUT > 0:
48+
self.socket.settimeout(DEFAULT_TIMEOUT)
3449

3550
def _close(self):
3651
self.socket.close()
@@ -57,7 +72,19 @@ def _read(self):
5772

5873
def _raw_read(self, length):
5974
while len(self.buffer) < length:
60-
data = self.socket.recv(64)
75+
try:
76+
data = self.socket.recv(64)
77+
except socket.timeout:
78+
# Name the cause. "timed out" alone sends people looking at the
79+
# test; the device is what stopped answering.
80+
raise IOError(
81+
'No response from the emulator at %s:%d after %gs -- it is '
82+
'not running, has crashed, or is wedged on a confirm screen '
83+
'nothing acknowledged. Set KK_UDP_TIMEOUT to change or 0 to '
84+
'disable.' % (self.device[0], self.device[1],
85+
DEFAULT_TIMEOUT))
86+
if not data:
87+
raise IOError('Emulator closed the connection')
6188
self.buffer += data[1:]
6289

6390
ret = self.buffer[:length]

tests/common.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,7 @@ def requires_structured_eip712(self):
156156
"""
157157
from keepkeylib import messages_ethereum_pb2 as _eth
158158
from keepkeylib import messages_pb2 as _proto
159+
from keepkeylib import types_pb2 as _types
159160

160161
probe = _eth.EthereumSignTypedData()
161162
for n in (0x8000002C, 0x8000003C, 0x80000000, 0, 0):
@@ -166,7 +167,7 @@ def requires_structured_eip712(self):
166167
resp = self.client.call_raw(probe)
167168
if isinstance(resp, _proto.Failure):
168169
self.client.init_device()
169-
if resp.code == _proto.Failure_UnexpectedMessage:
170+
if resp.code == _types.Failure_UnexpectedMessage:
170171
self.skipTest(
171172
"Firmware does not implement structured EIP-712 "
172173
"(EthereumSignTypedData is not handled)")

tests/test_msg_session_trust_lifetime.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,11 @@ def probe_blob():
9191
))
9292

9393

94+
# Names `ps -o comm=` reports for the emulator binary. Anything else bound to
95+
# the port is not ours to kill -- see the guard in _emulator_process().
96+
_EMULATOR_EXE_NAMES = ('kkemu',)
97+
98+
9499
def _emulator_process(port):
95100
"""(pid, exe, cwd) of the process BOUND to udp/port, or None.
96101
@@ -124,6 +129,20 @@ def _emulator_process(port):
124129
exe = subprocess.run(['ps', '-o', 'comm=', '-p', str(pid)],
125130
stdout=subprocess.PIPE,
126131
stderr=subprocess.PIPE, universal_newlines=True).stdout.strip()
132+
if os.path.basename(exe) not in _EMULATOR_EXE_NAMES:
133+
# Whatever holds this port, it is not the firmware. Whenever the
134+
# emulator runs in a container the bound process is the Docker
135+
# port forwarder -- docker-proxy or dockerd on Linux,
136+
# com.docker.backend on macOS -- in a different pid namespace
137+
# from kkemu. Killing it does not reboot anything: it removes
138+
# the port forward, and every later test in the run then blocks
139+
# forever on a socket that will never answer again. Measured
140+
# here: it took the whole Docker daemon down mid-suite.
141+
#
142+
# Fall through to "not found" so _power_cycle() takes its
143+
# documented skip, which the report renders as WITHHELD rather
144+
# than as a pass.
145+
continue
127146
cwd_out = subprocess.run(
128147
['lsof', '-a', '-p', str(pid), '-d', 'cwd', '-Fn'],
129148
stdout=subprocess.PIPE,

0 commit comments

Comments
 (0)