Form design pattern "Repeating as long as a condition is met" pathological infinite loop
- Dominant language
- TypeScript
- Stars
- 38
- Forks
- 22
- PR merge metrics
- No merged PRs in 30d
Description
The form design pattern is described in the XLSForm Reference section titled _[Repeating as long as a condition is met](https://docs.getodk.org/form-logic/#repeating-as-long-as-a-condition-is-met)_. There is potential in using this form design pattern to create a pathological case: an infinite loop, where the incrementing expression's condition is always satisfied by each newly added repeat instance.
This was discovered in [CI failures](https://github.com/getodk/web-forms/actions/runs/10600400699) after merging both #187 and #195, specifically failing on the child vaccination smoketest.
I had originally filed this as a bug, until I looked more closely at the child vaccination form definition. Here is the expression that controls the repeat's `jr:count` (formatted and parentheses for readability):
```xpath
if(
/data/building_type = 'single',
1,
if(
(
/data/flatcount = 0 or
indexed-repeat(/data/household/finalflat, /data/household, /data/flatcount) != 'yes'
),
/data/flatcount + 1,
/data/flatcount
)
)
```
I initially considered the possibility that the behavior was caused by reactive over-subscription, where the `indexed-repeat` call references the repeat itself (`/data/household`). So I tried replacing that call with its LocationPath equivalent, so it would reference only the repeat instance's descendant. Here is the same condition sub-expression, in that format:
```xpath
/data/flatcount = 0 or
/data/household[/data/flatcount]/finalflat != 'yes'
```
With this change, the form still exhibited the same behavior, ruling out that narrow hypothesis. I also considered the possibility of broader reactive over-subscription, implicating our implementations of:
- `EvaluationContext.getSubscribableDependenciesByReference`
- `SubscribableDependency.subscribe`
- Our approach to dependency analysis, which we know is intentionally designed to cast a wide net (trading performance for simplicity and correctness)
To investigate this, I updated my prototype of #203 (addressing #39) to account for the recently merged `jr:count`/`indexed-repeat` support. I found the behavior was still identical (albeit much faster to reach the failure mode!).
It was only after adding some logging there to better understand the infinite loop, that I noticed _the repeat count was continuously increasing until failure_. This got me to look more closely at the condition itself. Specifically here:
```xpath
/data/flatcount = 0 or
/data/household[/data/flatcount]/finalflat != 'yes'
^^^^^^^^
```
There's our infinite loop! Here's what happens:
1. On load, if there are zero repeat instances, the `jr:count` increments
2. On increment, a new repeat instance is created
3. Once added, the `jr:count` expression is rerun, checking `finalflat` in the repeat range's last repeat instance
4. That value's default state is blank, goto 3
We discussed this in Slack, where I theorized that the reason this works in Collect/JavaRosa is because computations are rerun based on the user's positional state within a form: the infinite loop exists, but does not advance to its next iteration until the user advances to the next repeat instance to trigger it! @lognaturel agreed with this theory.
As I said when I closed #206, we now feel the most appropriate course of action for the CI failure aspect of this issue is to create an alternate version of the form/smoketest which doesn't exhibit this pathology. In this case we can simply replace `!= 'yes'` with `= 'no'`.
As I understand it, we also believe the _form design pattern_—while error prone in this way—is relatively niche. It's likely we can address the pathological case with user support.
And again as mentioned in closing #206, I expect the alternate form will also require updating the smoketest itself to account for further progress into the form. Following that, I will likely want to do a very brief timeboxed spike into potential mitigations for this specific pathology.
I think I have an idea for a relatively simple special case: short circuit multiple `jr:count` changes occurring in the same event loop tick (or at least, in the same blocking period/sequence of contiguous microtasks). I think this is probably something we'll want not just for this pathological case, but to potentially detect others which may present similarly.
I'll also add for posterity that I believe we could detect this case with static analysis alone. I don't know if it's worth the effort! But it is definitely knowable, programmatically, from the form definition itself.
Contributor guide
No contributing guide indexed for this repository
Research direction
Start by reading the child vaccination CI failure and the implementations of EvaluationContext.getSubscribableDependenciesByReference and SubscribableDependency.subscribe mentioned in the issue. Confirm the pathological jr:count behavior, then evaluate the proposed timeboxed mitigation or static-analysis direction; completion criteria are not fully settled in the issue.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- typescript
- Domain
- frontend, testing-qa
- Issue type
- Bug
- Difficulty
- 5/5
- Estimated time
- Over a week
- Activity status
- Stale
- Clarity
- Mostly clear
- Newbie friendliness
- 25/100