ISISNeutronMuon / ISISNeutronMuon/p4pillon

IOCMimicServer does not serve all NT fields like real IOC

Open
#63 0 comments 0 reactions 1 assignee Claimed by @Monarda View on GitHub
bug enhancement
Dominant language
Python
Stars
2
Forks
0
PR merge metrics
No merged PRs in 30d

Description

## `SharedPV.open()` sends an incomplete structure, breaking strict pvAccess clients

A `SharedPV` opened with a partially-marked value never transmits the unmarked fields — not to the first client, and not to any client thereafter. Clients that zero-fill absent leaves hide this; clients that don't, break.

### Background

pvAccess puts only *marked* fields on the wire. A `SharedPV`'s stored change mask is the **union** of everything marked since `open()`, and `open()` resets that union. A field that is never marked is therefore never sent at all.

`p4p.nt.NTScalar(...).wrap(v)` marks only `value`. So a PV that is opened and not subsequently posted to — or one whose posts only ever touch `value` and `timeStamp` — serves a structure in which `alarm.*`, `display.*`, `control.*` and `valueAlarm.*` are permanently absent from the wire.

### Impact

The EPICS Archiver Appliance throws on connect to any such channel:

```
ERROR pv.EPICS_V4_PV - exception when reading pv
java.lang.NullPointerException
at EPICSEvent$FieldValue$Builder.setVal(EPICSEvent.java:909)
at PBScalarString.setFieldValues(PBScalarString.java:285)
at EPICS_V4_PV.saveAllMetaData(EPICS_V4_PV.java:594)
at EPICS_V4_PV.subscribe(EPICS_V4_PV.java:439)
```

The pure-Java `org.epics.pva` client constructs a `PVAString` with a `null` payload from the introspection descriptor and only `decode()`s the bits set in the change BitSet, so an untransmitted string stays `null`. The archiver's `FieldValuesCache.currentFieldValues()` flattens the structure with no null check, and `saveAllMetaData()`'s `getEverything` path bypasses `v3NamedValues()` — the only place that drops nulls — so the `null` reaches protobuf's `setVal`.

Only *strings* throw; `PVAInt` initialises to `0`. `alarm.message`, `display.description` and `display.units` are the exposed leaves.

The exception is caught by `subscribe()`, so the channel still connects — what is lost is the initial sample and all saved metadata, on every connect. The same client library backs Phoebus/CS-Studio, so the archiver is unlikely to be the only affected consumer.

### Which PVs are affected

Exposure is entirely "does anything ever mark this field":

| PV | marks `alarm.message`? |
|---|---|
| plain `p4pillon.server.thread.SharedPV`, no handler | never |
| `SharedNT`, `NTScalar`, no valueAlarm | never |
| `SharedNT`, `NTScalar`, valueAlarm active | only from the first alarm transition onward |
| `SharedNT`, `NTEnum` | at open (`AlarmNTEnumRule.init_rule` writes it unconditionally) |

Rows 2 and 3 are the same defect with a timing dependence: a client that connects before the first alarm transition receives the incomplete structure, one that connects after does not. `AlarmRule` does not help — it is read-only enforcement for puts and never writes a value.

`display.*` is owned by no rule at all, so an `NTScalar('d', display=True)` opened without display metadata never transmits `display.description` or `display.units` regardless of which rules are attached.

### This is not how a real IOC behaves

A live QSRV channel (`HELREC::DEWPOINT:READ`) marks **all 27 leaves** on a plain first get, including `display.limitLow`/`limitHigh` and `control.*` at their default `0.0`:

```
changedSet: alarm.message, alarm.severity, alarm.status, control.limitHigh,
control.limitLow, control.minStep, display.description, display.form.choices,
display.form.index, display.limitHigh, display.limitLow, display.precision,
display.units, timeStamp.nanoseconds, timeStamp.secondsPastEpoch,
timeStamp.userTag, value, valueAlarm.active, valueAlarm.highAlarmLimit,
valueAlarm.highAlarmSeverity, valueAlarm.highWarningLimit,
valueAlarm.highWarningSeverity, valueAlarm.hysteresis, valueAlarm.lowAlarmLimit,
valueAlarm.lowAlarmSeverity, valueAlarm.lowWarningLimit,
valueAlarm.lowWarningSeverity
```

QSRV does not use transmission-absence to signal "unset" — it carries that in-band (`valueAlarm.active = False`, `highAlarmSeverity = 0`, and
`highAlarmLimit = NaN`).

### Reproducing

```python
from p4p.nt import NTScalar
from p4p.server import Server, StaticProvider
from p4p.server.thread import SharedPV
from p4p.client.thread import Context

nt = NTScalar('d', display=True, valueAlarm=True)
pv = SharedPV(nt=nt, initial=nt.wrap(1.0))

prov = StaticProvider("probe")
prov.add("PROBE:PV", pv)
with Server(providers=[prov], isolate=True) as S, \
Context('pva', conf=S.conf(), useenv=False) as ctx:
raw = ctx.get("PROBE:PV").raw
print(sorted(raw.changedSet(expand=True))) # ['value']
print(repr(raw['alarm']['message'])) # '' -- null to a Java client
```

Contributor guide

No contributing guide indexed for this repository

Assessment

This issue has not been assessed yet.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.