Skip to content

Commit 79e87d1

Browse files
agnersj-ororke
authored andcommittedJul 31, 2024
[Python] Avoid RuntimeException if APIs with future raise an error (project-chip#34354)
Currently, when calling an API which uses a future causes an error (e.g. CommissionWithCode with an invalid code), then the API call already returns an error. In this case the call `raise_on_error()` on the returned PyChipError object make sure that an exception is raised. However, this also causes the `CallbackContext` context manager to exit. At this point the future is initialized but never completed, which triggers the previously introduced sanity check in `CallbackContext`: `RuntimeError("CallbackContext future not completed")`. Remove the RuntimeError as existing the context manager early without completing the future is a use case (when the call setting up the callback raises an exception). Instead, just cancel the future in the context manager if it hasn't been complete yet, in case someone has a reference to it and expects it to complete. Also, since most API calls return PyChipError, this changes `CallAsync()` to raise an exception by default instead of returning a PyChipError object. If the PyChipError object is required or an API returns something else, the CallAsyncWithResult() method can be used.
1 parent 3ad1472 commit 79e87d1

File tree

4 files changed

+32
-34
lines changed

4 files changed

+32
-34
lines changed
 

‎src/controller/python/chip/ChipDeviceCtrl.py

+23-30
Original file line numberDiff line numberDiff line change
@@ -249,7 +249,10 @@ def future(self) -> typing.Optional[concurrent.futures.Future]:
249249

250250
async def __aexit__(self, exc_type, exc_value, traceback):
251251
if not self._future.done():
252-
raise RuntimeError("CallbackContext future not completed")
252+
# In case the initial call (which sets up for the callback) fails,
253+
# the future will never be used actually. So just cancel it here
254+
# for completeness, in case somebody is expecting it to be completed.
255+
self._future.cancel()
253256
self._future = None
254257
self._lock.release()
255258

@@ -603,23 +606,22 @@ async def ConnectBLE(self, discriminator: int, setupPinCode: int, nodeid: int, i
603606

604607
async with self._commissioning_context as ctx:
605608
self._enablePairingCompleteCallback(True)
606-
res = await self._ChipStack.CallAsync(
609+
await self._ChipStack.CallAsync(
607610
lambda: self._dmLib.pychip_DeviceController_ConnectBLE(
608611
self.devCtrl, discriminator, isShortDiscriminator, setupPinCode, nodeid)
609612
)
610-
res.raise_on_error()
611613

612614
return await asyncio.futures.wrap_future(ctx.future)
613615

614616
async def UnpairDevice(self, nodeid: int) -> None:
615617
self.CheckIsActive()
616618

617619
async with self._unpair_device_context as ctx:
618-
res = await self._ChipStack.CallAsync(
620+
await self._ChipStack.CallAsync(
619621
lambda: self._dmLib.pychip_DeviceController_UnpairDevice(
620622
self.devCtrl, nodeid, self.cbHandleDeviceUnpairCompleteFunct)
621623
)
622-
res.raise_on_error()
624+
623625
return await asyncio.futures.wrap_future(ctx.future)
624626

625627
def CloseBLEConnection(self):
@@ -656,8 +658,7 @@ async def _establishPASESession(self, callFunct):
656658

657659
async with self._pase_establishment_context as ctx:
658660
self._enablePairingCompleteCallback(True)
659-
res = await self._ChipStack.CallAsync(callFunct)
660-
res.raise_on_error()
661+
await self._ChipStack.CallAsync(callFunct)
661662
await asyncio.futures.wrap_future(ctx.future)
662663

663664
async def EstablishPASESessionBLE(self, setupPinCode: int, discriminator: int, nodeid: int) -> None:
@@ -756,13 +757,12 @@ async def DiscoverCommissionableNodes(self, filterType: discovery.FilterType = d
756757
# Discovery is also used during commissioning. Make sure this manual discovery
757758
# and commissioning attempts do not interfere with each other.
758759
async with self._commissioning_lock:
759-
res = await self._ChipStack.CallAsync(
760+
await self._ChipStack.CallAsync(
760761
lambda: self._dmLib.pychip_DeviceController_DiscoverCommissionableNodes(
761762
self.devCtrl, int(filterType), str(filter).encode("utf-8")))
762-
res.raise_on_error()
763763

764764
async def _wait_discovery():
765-
while not await self._ChipStack.CallAsync(
765+
while not await self._ChipStack.CallAsyncWithResult(
766766
lambda: self._dmLib.pychip_DeviceController_HasDiscoveredCommissionableNode(self.devCtrl)):
767767
await asyncio.sleep(0.1)
768768
return
@@ -776,9 +776,8 @@ async def _wait_discovery():
776776
# Expected timeout, do nothing
777777
pass
778778
finally:
779-
res = await self._ChipStack.CallAsync(
779+
await self._ChipStack.CallAsync(
780780
lambda: self._dmLib.pychip_DeviceController_StopCommissionableDiscovery(self.devCtrl))
781-
res.raise_on_error()
782781

783782
return await self.GetDiscoveredDevices()
784783

@@ -796,7 +795,7 @@ def HandleDevice(deviceJson, deviceJsonLen):
796795
self._dmLib.pychip_DeviceController_IterateDiscoveredCommissionableNodes(devCtrl.devCtrl, HandleDevice)
797796
return devices
798797

799-
return await self._ChipStack.CallAsync(lambda: GetDevices(self))
798+
return await self._ChipStack.CallAsyncWithResult(lambda: GetDevices(self))
800799

801800
def GetIPForDiscoveredDevice(self, idx, addrStr, length):
802801
self.CheckIsActive()
@@ -828,11 +827,10 @@ async def OpenCommissioningWindow(self, nodeid: int, timeout: int, iteration: in
828827
self.CheckIsActive()
829828

830829
async with self._open_window_context as ctx:
831-
res = await self._ChipStack.CallAsync(
830+
await self._ChipStack.CallAsync(
832831
lambda: self._dmLib.pychip_DeviceController_OpenCommissioningWindow(
833832
self.devCtrl, self.pairingDelegate, nodeid, timeout, iteration, discriminator, option)
834833
)
835-
res.raise_on_error()
836834

837835
return await asyncio.futures.wrap_future(ctx.future)
838836

@@ -896,14 +894,14 @@ async def FindOrEstablishPASESession(self, setupCode: str, nodeid: int, timeoutM
896894
''' Returns CommissioneeDeviceProxy if we can find or establish a PASE connection to the specified device'''
897895
self.CheckIsActive()
898896
returnDevice = c_void_p(None)
899-
res = await self._ChipStack.CallAsync(lambda: self._dmLib.pychip_GetDeviceBeingCommissioned(
897+
res = await self._ChipStack.CallAsyncWithResult(lambda: self._dmLib.pychip_GetDeviceBeingCommissioned(
900898
self.devCtrl, nodeid, byref(returnDevice)), timeoutMs)
901899
if res.is_success:
902900
return DeviceProxyWrapper(returnDevice, DeviceProxyWrapper.DeviceProxyType.COMMISSIONEE, self._dmLib)
903901

904902
await self.EstablishPASESession(setupCode, nodeid)
905903

906-
res = await self._ChipStack.CallAsync(lambda: self._dmLib.pychip_GetDeviceBeingCommissioned(
904+
res = await self._ChipStack.CallAsyncWithResult(lambda: self._dmLib.pychip_GetDeviceBeingCommissioned(
907905
self.devCtrl, nodeid, byref(returnDevice)), timeoutMs)
908906
if res.is_success:
909907
return DeviceProxyWrapper(returnDevice, DeviceProxyWrapper.DeviceProxyType.COMMISSIONEE, self._dmLib)
@@ -991,7 +989,7 @@ async def GetConnectedDevice(self, nodeid, allowPASE: bool = True, timeoutMs: in
991989

992990
if allowPASE:
993991
returnDevice = c_void_p(None)
994-
res = await self._ChipStack.CallAsync(lambda: self._dmLib.pychip_GetDeviceBeingCommissioned(
992+
res = await self._ChipStack.CallAsyncWithResult(lambda: self._dmLib.pychip_GetDeviceBeingCommissioned(
995993
self.devCtrl, nodeid, byref(returnDevice)), timeoutMs)
996994
if res.is_success:
997995
LOGGER.info('Using PASE connection')
@@ -1021,10 +1019,9 @@ def deviceAvailable(self, device, err):
10211019

10221020
closure = DeviceAvailableClosure(eventLoop, future)
10231021
ctypes.pythonapi.Py_IncRef(ctypes.py_object(closure))
1024-
res = await self._ChipStack.CallAsync(lambda: self._dmLib.pychip_GetConnectedDeviceByNodeId(
1022+
await self._ChipStack.CallAsync(lambda: self._dmLib.pychip_GetConnectedDeviceByNodeId(
10251023
self.devCtrl, nodeid, ctypes.py_object(closure), _DeviceAvailableCallback),
10261024
timeoutMs)
1027-
res.raise_on_error()
10281025

10291026
# The callback might have been received synchronously (during self._ChipStack.CallAsync()).
10301027
# In that case the Future has already been set it will return immediately
@@ -1917,11 +1914,10 @@ async def Commission(self, nodeid) -> int:
19171914

19181915
async with self._commissioning_context as ctx:
19191916
self._enablePairingCompleteCallback(False)
1920-
res = await self._ChipStack.CallAsync(
1917+
await self._ChipStack.CallAsync(
19211918
lambda: self._dmLib.pychip_DeviceController_Commission(
19221919
self.devCtrl, nodeid)
19231920
)
1924-
res.raise_on_error()
19251921

19261922
return await asyncio.futures.wrap_future(ctx.future)
19271923

@@ -2065,11 +2061,10 @@ async def CommissionOnNetwork(self, nodeId: int, setupPinCode: int,
20652061

20662062
async with self._commissioning_context as ctx:
20672063
self._enablePairingCompleteCallback(True)
2068-
res = await self._ChipStack.CallAsync(
2064+
await self._ChipStack.CallAsync(
20692065
lambda: self._dmLib.pychip_DeviceController_OnNetworkCommission(
20702066
self.devCtrl, self.pairingDelegate, nodeId, setupPinCode, int(filterType), str(filter).encode("utf-8") if filter is not None else None, discoveryTimeoutMsec)
20712067
)
2072-
res.raise_on_error()
20732068

20742069
return await asyncio.futures.wrap_future(ctx.future)
20752070

@@ -2086,11 +2081,10 @@ async def CommissionWithCode(self, setupPayload: str, nodeid: int, discoveryType
20862081

20872082
async with self._commissioning_context as ctx:
20882083
self._enablePairingCompleteCallback(True)
2089-
res = await self._ChipStack.CallAsync(
2084+
await self._ChipStack.CallAsync(
20902085
lambda: self._dmLib.pychip_DeviceController_ConnectWithCode(
20912086
self.devCtrl, setupPayload.encode("utf-8"), nodeid, discoveryType.value)
20922087
)
2093-
res.raise_on_error()
20942088

20952089
return await asyncio.futures.wrap_future(ctx.future)
20962090

@@ -2106,11 +2100,10 @@ async def CommissionIP(self, ipaddr: str, setupPinCode: int, nodeid: int) -> int
21062100

21072101
async with self._commissioning_context as ctx:
21082102
self._enablePairingCompleteCallback(True)
2109-
res = await self._ChipStack.CallAsync(
2103+
await self._ChipStack.CallAsync(
21102104
lambda: self._dmLib.pychip_DeviceController_ConnectIP(
21112105
self.devCtrl, ipaddr.encode("utf-8"), setupPinCode, nodeid)
21122106
)
2113-
res.raise_on_error()
21142107

21152108
return await asyncio.futures.wrap_future(ctx.future)
21162109

@@ -2127,11 +2120,11 @@ async def IssueNOCChain(self, csr: Clusters.OperationalCredentials.Commands.CSRR
21272120
self.CheckIsActive()
21282121

21292122
async with self._issue_node_chain_context as ctx:
2130-
res = await self._ChipStack.CallAsync(
2123+
await self._ChipStack.CallAsync(
21312124
lambda: self._dmLib.pychip_DeviceController_IssueNOCChain(
21322125
self.devCtrl, py_object(self), csr.NOCSRElements, len(csr.NOCSRElements), nodeId)
21332126
)
2134-
res.raise_on_error()
2127+
21352128
return await asyncio.futures.wrap_future(ctx.future)
21362129

21372130

‎src/controller/python/chip/ChipStack.py

+6-1
Original file line numberDiff line numberDiff line change
@@ -216,7 +216,7 @@ def Call(self, callFunct, timeoutMs: int = None):
216216
'''
217217
return self.PostTaskOnChipThread(callFunct).Wait(timeoutMs)
218218

219-
async def CallAsync(self, callFunct, timeoutMs: int = None):
219+
async def CallAsyncWithResult(self, callFunct, timeoutMs: int = None):
220220
'''Run a Python function on CHIP stack, and wait for the response.
221221
This function will post a task on CHIP mainloop and waits for the call response in a asyncio friendly manner.
222222
'''
@@ -232,6 +232,11 @@ async def CallAsync(self, callFunct, timeoutMs: int = None):
232232

233233
return await asyncio.wait_for(callObj.future, timeoutMs / 1000 if timeoutMs else None)
234234

235+
async def CallAsync(self, callFunct, timeoutMs: int = None) -> None:
236+
'''Run a Python function on CHIP stack, and wait for the response.'''
237+
res: PyChipError = await self.CallAsyncWithResult(callFunct, timeoutMs)
238+
res.raise_on_error()
239+
235240
def PostTaskOnChipThread(self, callFunct) -> AsyncCallableHandle:
236241
'''Run a Python function on CHIP stack, and wait for the response.
237242
This function will post a task on CHIP mainloop, and return an object with Wait() method for getting the result.

‎src/controller/python/chip/clusters/Attribute.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -469,7 +469,7 @@ def OverrideLivenessTimeoutMs(self, timeoutMs: int):
469469

470470
async def TriggerResubscribeIfScheduled(self, reason: str):
471471
handle = chip.native.GetLibraryHandle()
472-
await builtins.chipStack.CallAsync(
472+
await builtins.chipStack.CallAsyncWithResult(
473473
lambda: handle.pychip_ReadClient_TriggerResubscribeIfScheduled(
474474
self._readTransaction._pReadClient, reason.encode("utf-8"))
475475
)

‎src/controller/python/chip/clusters/Command.py

+2-2
Original file line numberDiff line numberDiff line change
@@ -316,7 +316,7 @@ async def SendCommand(future: Future, eventLoop, responseType: Type, device, com
316316

317317
payloadTLV = payload.ToTLV()
318318
ctypes.pythonapi.Py_IncRef(ctypes.py_object(transaction))
319-
return await builtins.chipStack.CallAsync(
319+
return await builtins.chipStack.CallAsyncWithResult(
320320
lambda: handle.pychip_CommandSender_SendCommand(
321321
ctypes.py_object(transaction), device,
322322
c_uint16(0 if timedRequestTimeoutMs is None else timedRequestTimeoutMs), commandPath.EndpointId,
@@ -388,7 +388,7 @@ async def SendBatchCommands(future: Future, eventLoop, device, commands: List[In
388388
transaction = AsyncBatchCommandsTransaction(future, eventLoop, responseTypes)
389389
ctypes.pythonapi.Py_IncRef(ctypes.py_object(transaction))
390390

391-
return await builtins.chipStack.CallAsync(
391+
return await builtins.chipStack.CallAsyncWithResult(
392392
lambda: handle.pychip_CommandSender_SendBatchCommands(
393393
py_object(transaction), device,
394394
c_uint16(0 if timedRequestTimeoutMs is None else timedRequestTimeoutMs),

0 commit comments

Comments
 (0)