feat(dispenser): add worker-owned idle card prestaging
This commit is contained in:
parent
b9b2a524dd
commit
aef1bbbe57
@ -33,7 +33,7 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
buildVersion = "v2.1.0"
|
||||
buildVersion = "v2.1.1"
|
||||
serviceName = "hardlink"
|
||||
pollingFrequency = 8 * time.Second
|
||||
)
|
||||
@ -227,6 +227,7 @@ func main() {
|
||||
|
||||
// Start polling for dispenser status every 10 seconds
|
||||
disp.StartPolling(pollingFrequency)
|
||||
disp.StartMaintenance()
|
||||
}
|
||||
|
||||
mux := http.NewServeMux()
|
||||
|
||||
335
internal/dispenser/K720_DISPENSER_CONTRACT.md
Normal file
335
internal/dispenser/K720_DISPENSER_CONTRACT.md
Normal file
@ -0,0 +1,335 @@
|
||||
# K720 dispenser issuance contract
|
||||
|
||||
Hardlink treats the K720 as an unreliable mechanical peripheral with a strict transport
|
||||
protocol and imperfect status telemetry. The implementation must keep transport
|
||||
validation strict while making mechanical decisions from fresh, bounded observations.
|
||||
Diagnostic bytes are useful evidence, but they must not override a valid physical
|
||||
position or turn an uncertain observation into a fatal guest-facing error.
|
||||
|
||||
This document records the dispenser invariants agreed for the clean implementation.
|
||||
It is intended to be a design contract: future changes should preserve these rules
|
||||
unless production evidence justifies changing them explicitly.
|
||||
|
||||
## Ownership and concurrency
|
||||
|
||||
- The dispenser worker is the single owner of serial commands and `deliveryPending`.
|
||||
New code must not access the serial port directly from handlers or background
|
||||
goroutines.
|
||||
- Commands that can move a card are serialized through the worker. There must never
|
||||
be concurrent AP/FC7/FC0/RS traffic from separate flows.
|
||||
- `deliveryPending` is worker-owned state. It must not be duplicated or mutated by
|
||||
HTTP handlers.
|
||||
- A real `/issuedoorcard` operation always has priority over maintenance/prestage
|
||||
work.
|
||||
- Cancellation of a real caller must stop caller-owned work. Internal bounded
|
||||
observation timeouts must remain distinguishable from caller cancellation.
|
||||
- Do not create overlapping maintenance goroutines or timers per request. Any
|
||||
background maintenance must have one clear owner and lifecycle.
|
||||
|
||||
## Status and observation model
|
||||
|
||||
AP transport parsing is strict. A response is usable only when its framing and
|
||||
contents are valid, including ACK/address/header/length/ETX/BCC/type checks.
|
||||
|
||||
Malformed, truncated, timed-out or otherwise invalid AP responses are unusable
|
||||
observations. They are logged and discarded. They are never converted into a
|
||||
fabricated physical state such as `0x30` or `0x38`.
|
||||
|
||||
Every fresh valid AP observation reclassifies the current mechanical state. No
|
||||
physical position is latched across later observations.
|
||||
|
||||
The fourth AP status byte is the physical position used by the preparation logic:
|
||||
|
||||
- Encoder confirmed:
|
||||
`0x32, 0x33, 0x36, 0x37, 0x3A, 0x3B, 0x3E, 0x3F`.
|
||||
A card may be handed to `LockSequence` immediately.
|
||||
- Valid but uncertain:
|
||||
`0x31, 0x34, 0x35, 0x39, 0x3C, 0x3D`.
|
||||
Continue bounded fresh observation; if no stronger state appears, one encoding
|
||||
opportunity may still be granted after the uncertainty window.
|
||||
- No card on sensors:
|
||||
exact `0x30`.
|
||||
This may trigger the bounded mechanical recovery sequence.
|
||||
- Card well empty:
|
||||
exact `0x38`.
|
||||
This is the only physical state that becomes `ErrCardWellEmpty`.
|
||||
|
||||
The first three diagnostic bytes are advisory. Values such as `Preparing card fails`,
|
||||
`Dispense card error`, `Card jammed`, or `Command cannot execute` may coexist with a
|
||||
usable physical position. They may be logged, but they must not automatically block
|
||||
encoding when the fourth byte confirms a usable card position.
|
||||
|
||||
## Preparing the current card
|
||||
|
||||
A `/issuedoorcard` request receives at most one `LockSequence` opportunity.
|
||||
|
||||
Preparation uses bounded fresh observation:
|
||||
|
||||
- Poll interval: 1 second.
|
||||
- After a successful FC7, allow 3 seconds of observation before shake recovery is
|
||||
eligible for a persistent exact `0x30`.
|
||||
- For uncertain or unusable observations after a successful FC7, use one shared
|
||||
4-second uncertainty window. Switching between uncertain and unusable observations
|
||||
does not restart that window.
|
||||
- A later usable observation always takes precedence:
|
||||
encoder confirmed -> handoff;
|
||||
exact `0x38` -> empty;
|
||||
exact `0x30` -> resume the remaining shake budget and reset uncertainty timing.
|
||||
- Total preparation budget: 32 seconds.
|
||||
|
||||
For persistent exact `0x30`, the request may perform at most three shake recoveries:
|
||||
|
||||
`RS -> 2 second settle -> FC7`
|
||||
|
||||
There is no additional plain FC7 retry. Including the initial dispatch, the maximum
|
||||
per request is four FC7 commands and three RS commands.
|
||||
|
||||
If all three shakes are exhausted while the latest usable physical state remains
|
||||
exact `0x30`, preparation fails. If the internal preparation deadline is reached
|
||||
while the latest state is uncertain or unusable after a successful FC7, the request
|
||||
may hand off one encoding opportunity. Exact `0x38` remains empty.
|
||||
|
||||
FC7 or RS dispatch failures are real transport/command failures. They are not
|
||||
converted into status uncertainty and are not retried merely because their dispatch
|
||||
failed.
|
||||
|
||||
## Encoding and delivery
|
||||
|
||||
`LockSequence` runs exactly once for a prepared card in a single `/issuedoorcard`
|
||||
request. The dispenser layer does not implement a second encoding attempt on the same
|
||||
physical card.
|
||||
|
||||
After `LockSequence`, FC0 is dispatched exactly once to move the current card toward
|
||||
the guest:
|
||||
|
||||
- `LockSequence` success -> FC0 once -> successful issuance remains successful.
|
||||
- `LockSequence` failure -> FC0 once -> return the original encoding error.
|
||||
- FC0 errors are logged only. They do not replace the original encoding result and
|
||||
they do not become `ErrCardWellEmpty`.
|
||||
|
||||
A failed `LockSequence` does not start next-card prestaging.
|
||||
|
||||
The design intentionally does not add retained-card state, per-card failure counters,
|
||||
provider-specific encoder retries, special bad-card recovery, CP/capture recovery, or
|
||||
multiple encoding attempts per physical card without production evidence requiring
|
||||
them.
|
||||
|
||||
## Delivery clearance
|
||||
|
||||
After FC0, the worker marks the previous delivery as pending. A later FC7 must not be
|
||||
sent until the previous delivery has been considered clear.
|
||||
|
||||
Delivery clearance uses fresh AP observations and is owned by the worker.
|
||||
|
||||
Current bounds:
|
||||
|
||||
- Minimum clearance wait: 2 seconds.
|
||||
- Clearance timeout: 6 seconds.
|
||||
- Poll interval: 1 second.
|
||||
|
||||
A valid physical position of `0x30` or `0x34` may clear `deliveryPending` after the
|
||||
minimum wait unless the same fresh observation explicitly indicates active
|
||||
preparing/dispensing/capturing movement.
|
||||
|
||||
Exact `0x38` reports physical empty and does not silently clear the state.
|
||||
|
||||
If the internal clearance timeout is reached while the caller context is still
|
||||
valid, the worker may make the bounded assumption that delivery has cleared unless
|
||||
the latest fresh usable observation establishes exact physical empty (`0x38`) or
|
||||
explicit active movement. This fallback may therefore occur while the latest
|
||||
physical position is `0x33`; `0x33` itself is not positive clearance evidence.
|
||||
The fallback is an internal timeout policy, not a reclassification of the observed
|
||||
position.
|
||||
|
||||
If the latest fresh usable observation still explicitly indicates movement, that is
|
||||
not treated as unknown. `deliveryPending` remains set and the clearance attempt
|
||||
times out.
|
||||
|
||||
Caller cancellation or deadline always takes precedence over the internal clearance
|
||||
fallback. If the caller context expires, return the caller error and do not admit a
|
||||
subsequent FC7 from that caller-owned operation.
|
||||
|
||||
A later unusable observation supersedes older movement evidence; stale movement
|
||||
information must not be latched indefinitely.
|
||||
|
||||
## Next-card prestage and guest UX
|
||||
|
||||
Preparing the next card is an optimization for the next guest, not part of the
|
||||
business success of the current guest's issuance.
|
||||
|
||||
After a successful `LockSequence` and FC0, Hardlink may make a short best-effort
|
||||
attempt to prestage the next card:
|
||||
|
||||
- wait for worker-owned delivery clearance;
|
||||
- if clearance is obtained within the bounded prestage context, send exactly one FC7;
|
||||
- do not run readiness polling, RS/shake recovery, or the full current-card
|
||||
preparation flow;
|
||||
- prestage failure is log-only and must not change the successful `/issuedoorcard`
|
||||
result.
|
||||
|
||||
Do not extend the current guest's screen by 15-20 seconds merely to guarantee that
|
||||
the next card reaches the encoder. The guest-facing flow must remain bounded even
|
||||
when the dispenser is slow to become ready for prestage.
|
||||
|
||||
If the short prestage window expires, skip that prestage attempt. A later request or
|
||||
maintenance cycle may prepare the next card.
|
||||
|
||||
## HTTP contract
|
||||
|
||||
Physical empty and operational failure are deliberately different outcomes.
|
||||
|
||||
- HTTP 503 is reserved for `ErrCardWellEmpty`, derived only from an exact valid
|
||||
physical `0x38`.
|
||||
- All other dispenser preparation, transport, command and encoding failures return
|
||||
HTTP 502.
|
||||
- Normal request/protocol validation keeps its existing 400/405/415 behavior.
|
||||
- Diagnostic text such as `Preparing card fails` must never by itself produce 503.
|
||||
|
||||
Operafyne treats:
|
||||
|
||||
- 502 as retryable while attempts remain;
|
||||
- 503 as terminal `dispenser_failed`;
|
||||
- a maximum of three total issue attempts: the initial attempt plus up to two user
|
||||
retries.
|
||||
|
||||
The dispenser layer must preserve this distinction.
|
||||
|
||||
## Passive status and alerts
|
||||
|
||||
Ordinary status queries remain strict and passive. They must not reuse tolerant
|
||||
issuance semantics to fabricate a status or hide malformed transport.
|
||||
|
||||
Passive polling captures the current foreground activity generation when the poll is
|
||||
queued. The worker skips a passive poll if foreground activity is active when it is
|
||||
dispatched or if the captured generation has become obsolete. Passive polling does
|
||||
not reset the idle-maintenance clock.
|
||||
|
||||
Status/diagnostic observations may be useful for logs and support alerts, but alerts
|
||||
must not alter physical state classification or issuance success.
|
||||
|
||||
Idle maintenance observations are intentionally quiet: they do not invoke the normal
|
||||
stock-update callback or generate repeated support email such as `Preparing card
|
||||
fails`. Maintenance failures are local diagnostic events only.
|
||||
|
||||
## Idle prestage maintenance
|
||||
|
||||
Idle prestaging recovers from a short post-FC0 prestage timeout without keeping the
|
||||
current guest waiting.
|
||||
|
||||
The implementation uses the existing dispenser worker as the single maintenance
|
||||
owner:
|
||||
|
||||
- one resettable maintenance timer is owned by the serial-worker loop; there is no
|
||||
maintenance goroutine and no timer created per request;
|
||||
- `/issuedoorcard` and `/testissuedoorcard` register foreground activity before
|
||||
using the dispenser/encoder and release it when the handler finishes;
|
||||
- foreground activity is reference-counted so overlapping requests suppress
|
||||
maintenance until the last active request finishes;
|
||||
- each foreground registration increments an activity generation and invalidates the
|
||||
previous idle deadline;
|
||||
- when the last foreground request finishes, the next maintenance attempt is
|
||||
scheduled one minute later;
|
||||
- enabling maintenance at startup schedules the first check one minute later when
|
||||
no foreground request is active;
|
||||
- a stale timer or stale generation cannot perform maintenance;
|
||||
- after a maintenance attempt, the next check is scheduled one minute from completion
|
||||
only if the same generation is still authoritative. A later foreground completion
|
||||
therefore cannot have its newer deadline overwritten by an older maintenance
|
||||
attempt.
|
||||
|
||||
Foreground registration and maintenance transaction admission use the same activity
|
||||
guard. The guard is used only for admission/state bookkeeping and is never held
|
||||
across serial I/O, queue waits, sleeps, callbacks, or `LockSequence`.
|
||||
|
||||
A maintenance attempt is deliberately weaker than foreground preparation:
|
||||
|
||||
1. create a bounded 5-second maintenance context;
|
||||
2. recheck maintenance admission before AP;
|
||||
3. obtain one fresh AP using the existing strict transport parser;
|
||||
4. classify the fresh status using the common physical classifier;
|
||||
5. do nothing for encoder-present, exact `0x38`, unusable, explicit-movement, or
|
||||
otherwise ineligible observations;
|
||||
6. when `deliveryPending` is set, require fresh qualifying clearance evidence and the
|
||||
existing minimum clearance wait before clearing it;
|
||||
7. never use the foreground six-second assumed-clearance fallback;
|
||||
8. recheck foreground count, activity generation, maintenance enabled state, and
|
||||
maintenance context immediately before FC7 admission;
|
||||
9. dispatch at most one FC7 and return immediately.
|
||||
|
||||
If foreground activity begins while an already-admitted maintenance AP is in
|
||||
progress, that AP may finish, but the second admission check prevents maintenance
|
||||
from sending FC7 afterward. An FC7 that was already admitted and dispatched before
|
||||
foreground registration cannot be recalled.
|
||||
|
||||
Maintenance never performs:
|
||||
|
||||
- RS or shake recovery;
|
||||
- `LockSequence`;
|
||||
- `PrepareCurrentCard`;
|
||||
- `PrepareNextCard` or `BeginPrepareNextCard`;
|
||||
- readiness polling after FC7;
|
||||
- the 32-second foreground preparation flow;
|
||||
- repeated FC7 attempts;
|
||||
- the foreground assumed-clearance fallback.
|
||||
|
||||
The 5-second maintenance context bounds new admissions and context-aware waits. An
|
||||
already-admitted serial read may still finish according to the existing serial read
|
||||
timeout, but no subsequent maintenance transaction is admitted after cancellation or
|
||||
expiry.
|
||||
|
||||
`StartMaintenance` and `StopMaintenance` are idempotent lifecycle operations.
|
||||
`Client.Close` disables maintenance and stops the timer.
|
||||
Stopping maintenance cancels the current maintenance context and waits only for an
|
||||
already-admitted maintenance transaction to finish under the existing bounded serial
|
||||
behavior.
|
||||
|
||||
## Automated verification
|
||||
|
||||
Tests should preserve the behavioural contract rather than only exercise individual
|
||||
functions.
|
||||
|
||||
At minimum, cover:
|
||||
|
||||
- strict AP framing and malformed/truncated/timeout responses;
|
||||
- tolerant issuance observations never fabricating `0x30` or `0x38`;
|
||||
- every physical position class and transitions between classes;
|
||||
- exact `0x38` as the only `ErrCardWellEmpty` path;
|
||||
- persistent `0x30` using at most three `RS -> settle -> FC7` recoveries;
|
||||
- no extra FC7 after the shake budget;
|
||||
- uncertain/unusable shared timing and later usable-state precedence;
|
||||
- caller cancellation versus internal preparation timeout;
|
||||
- exactly one `LockSequence` opportunity per request;
|
||||
- FC0 exactly once after encoding success or failure;
|
||||
- FC0 failure remaining log-only;
|
||||
- failed encoding never starting prestage;
|
||||
- worker-owned `deliveryPending` and guarded FC7 dispatch;
|
||||
- bounded delivery-clearance fallback and explicit-movement timeout;
|
||||
- successful issuance remaining successful when next-card prestage fails;
|
||||
- HTTP 503 only for exact physical empty and 502 for other issuance failures;
|
||||
- Operafyne retry semantics remaining three total attempts.
|
||||
|
||||
Idle maintenance verification additionally covers:
|
||||
|
||||
- first attempt occurs one minute after the latest issuance finishes;
|
||||
- a new issuance resets that idle interval;
|
||||
- overlapping foreground requests suppress maintenance until the last request
|
||||
finishes;
|
||||
- both `/issuedoorcard` and `/testissuedoorcard` participate in foreground activity
|
||||
registration;
|
||||
- stale timer events and stale activity generations cannot perform maintenance;
|
||||
- foreground registration before maintenance AP prevents AP admission;
|
||||
- foreground registration during an admitted AP prevents the later FC7;
|
||||
- passive polls are skipped while foreground activity is active or when their
|
||||
captured generation is stale;
|
||||
- maintenance performs one AP and at most one FC7, with no RS/shake/encoding;
|
||||
- encoder-present, exact-empty, movement and unusable observations are no-ops;
|
||||
- pending delivery requires fresh normal clearance plus the minimum wait and never
|
||||
uses the foreground assumed-clearance fallback;
|
||||
- maintenance errors do not produce guest-facing failures, stock callbacks or
|
||||
repeated alert email;
|
||||
- repeated maintenance start/stop calls are safe and do not accumulate work;
|
||||
- cancellation, `StopMaintenance` and `Client.Close` prevent subsequent maintenance
|
||||
commands after shutdown admission is revoked.
|
||||
|
||||
For Hardlink verification, continue excluding the existing test that sends real
|
||||
email when running the full suite.
|
||||
@ -23,9 +23,11 @@ const (
|
||||
)
|
||||
|
||||
type cmdReq struct {
|
||||
typ cmdType
|
||||
ctx context.Context
|
||||
respCh chan cmdResp
|
||||
passive bool
|
||||
generation uint64
|
||||
typ cmdType
|
||||
ctx context.Context
|
||||
respCh chan cmdResp
|
||||
}
|
||||
|
||||
type cmdResp struct {
|
||||
@ -51,7 +53,10 @@ const (
|
||||
)
|
||||
|
||||
type Client struct {
|
||||
port serialTransport
|
||||
activity activityGuard
|
||||
activityWake chan struct{}
|
||||
closeOnce sync.Once
|
||||
port serialTransport
|
||||
|
||||
reqCh chan cmdReq
|
||||
done chan struct{}
|
||||
@ -80,9 +85,10 @@ func NewClient(port *serial.Port, queueSize int) *Client {
|
||||
queueSize = 16
|
||||
}
|
||||
c := &Client{
|
||||
port: port,
|
||||
reqCh: make(chan cmdReq, queueSize),
|
||||
done: make(chan struct{}),
|
||||
activityWake: make(chan struct{}, 1),
|
||||
port: port,
|
||||
reqCh: make(chan cmdReq, queueSize),
|
||||
done: make(chan struct{}),
|
||||
sequenceTiming: sequenceTiming{
|
||||
now: time.Now,
|
||||
wait: waitForSequence,
|
||||
@ -106,12 +112,13 @@ func waitForSequence(ctx context.Context, duration time.Duration) error {
|
||||
}
|
||||
|
||||
func (c *Client) Close() {
|
||||
select {
|
||||
case <-c.done:
|
||||
return
|
||||
default:
|
||||
c.closeOnce.Do(func() {
|
||||
c.activity.Lock()
|
||||
c.activity.closed = true
|
||||
c.activity.Unlock()
|
||||
c.StopMaintenance()
|
||||
close(c.done)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// SetStatusTTL sets the duration for which cached status is considered fresh.
|
||||
@ -150,7 +157,7 @@ func (c *Client) setStock(statusBytes []byte) {
|
||||
}
|
||||
|
||||
// StartPolling performs a periodic status refresh.
|
||||
// It will NOT interrupt commands: it enqueues only when queue is idle.
|
||||
// Passive requests are admitted by the worker only for the captured idle generation.
|
||||
func (c *Client) StartPolling(interval time.Duration) {
|
||||
if interval <= 0 {
|
||||
return
|
||||
@ -168,8 +175,13 @@ func (c *Client) StartPolling(interval time.Duration) {
|
||||
if len(c.reqCh) != 0 {
|
||||
continue
|
||||
}
|
||||
generation, idle := c.passiveGeneration()
|
||||
if !idle {
|
||||
continue
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
_, err := c.CheckStatus(ctx)
|
||||
response := c.sendRequest(cmdReq{typ: cmdStatus, ctx: ctx, passive: true, generation: generation})
|
||||
err := response.err
|
||||
if err != nil {
|
||||
log.Debugf("dispenser polling: %v", err)
|
||||
}
|
||||
@ -180,12 +192,28 @@ func (c *Client) StartPolling(interval time.Duration) {
|
||||
}
|
||||
|
||||
func (c *Client) loop() {
|
||||
timer := time.NewTimer(time.Hour)
|
||||
defer timer.Stop()
|
||||
for {
|
||||
if !timer.Stop() {
|
||||
select {
|
||||
case <-timer.C:
|
||||
default:
|
||||
}
|
||||
}
|
||||
var tick <-chan time.Time
|
||||
if delay, enabled := c.maintenanceDelay(); enabled {
|
||||
timer.Reset(delay)
|
||||
tick = timer.C
|
||||
}
|
||||
select {
|
||||
case <-c.done:
|
||||
return
|
||||
case <-c.activityWake:
|
||||
case req := <-c.reqCh:
|
||||
c.handle(req)
|
||||
case <-tick:
|
||||
c.maintainIdleCard()
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -198,6 +226,21 @@ func (c *Client) handle(req cmdReq) {
|
||||
default:
|
||||
}
|
||||
|
||||
if req.passive {
|
||||
if !c.admitPassive(req.ctx, req.generation) {
|
||||
req.respCh <- cmdResp{}
|
||||
return
|
||||
}
|
||||
c.mu.RLock()
|
||||
st := append([]byte(nil), c.lastStatus...)
|
||||
fresh := len(st) == 4 && time.Since(c.lastStatusT) <= c.statusTTL
|
||||
c.mu.RUnlock()
|
||||
if fresh {
|
||||
c.setStock(st)
|
||||
req.respCh <- cmdResp{status: st}
|
||||
return
|
||||
}
|
||||
}
|
||||
switch req.typ {
|
||||
case cmdStatus:
|
||||
st, err := c.readWorkerStatus(req.ctx)
|
||||
@ -300,20 +343,33 @@ func (c *Client) do(ctx context.Context, typ cmdType) ([]byte, error) {
|
||||
}
|
||||
|
||||
func (c *Client) doResponse(ctx context.Context, typ cmdType) cmdResp {
|
||||
rch := make(chan cmdResp, 1)
|
||||
req := cmdReq{typ: typ, ctx: ctx, respCh: rch}
|
||||
return c.sendRequest(cmdReq{typ: typ, ctx: ctx})
|
||||
}
|
||||
|
||||
func (c *Client) sendRequest(req cmdReq) cmdResp {
|
||||
if err := req.ctx.Err(); err != nil {
|
||||
return cmdResp{err: err}
|
||||
}
|
||||
req.respCh = make(chan cmdResp, 1)
|
||||
select {
|
||||
case <-c.done:
|
||||
return cmdResp{err: context.Canceled}
|
||||
default:
|
||||
}
|
||||
select {
|
||||
case c.reqCh <- req:
|
||||
case <-ctx.Done():
|
||||
return cmdResp{err: ctx.Err()}
|
||||
case <-c.done:
|
||||
return cmdResp{err: context.Canceled}
|
||||
case <-req.ctx.Done():
|
||||
return cmdResp{err: req.ctx.Err()}
|
||||
}
|
||||
|
||||
select {
|
||||
case r := <-rch:
|
||||
case r := <-req.respCh:
|
||||
return r
|
||||
case <-ctx.Done():
|
||||
return cmdResp{err: ctx.Err()}
|
||||
case <-c.done:
|
||||
return cmdResp{err: context.Canceled}
|
||||
case <-req.ctx.Done():
|
||||
return cmdResp{err: req.ctx.Err()}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
180
internal/dispenser/maintenance.go
Normal file
180
internal/dispenser/maintenance.go
Normal file
@ -0,0 +1,180 @@
|
||||
package dispenser
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
const maintenanceInterval = time.Minute
|
||||
const maintenanceTimeout = 5 * time.Second
|
||||
|
||||
// activityGuard arbitrates admission, not the duration of serial transactions.
|
||||
// The serial worker remains the sole owner of the port and deliveryPending.
|
||||
type activityGuard struct {
|
||||
sync.Mutex
|
||||
foreground int
|
||||
generation uint64
|
||||
enabled bool
|
||||
closed bool
|
||||
next time.Time
|
||||
cancel context.CancelFunc
|
||||
finished chan struct{}
|
||||
}
|
||||
|
||||
func (c *Client) wakeWorker() {
|
||||
select {
|
||||
case c.activityWake <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
// BeginForeground suppresses idle work until the returned release function runs.
|
||||
func (c *Client) BeginForeground() func() {
|
||||
c.activity.Lock()
|
||||
c.activity.foreground++
|
||||
c.activity.generation++
|
||||
c.activity.next = time.Time{}
|
||||
c.activity.Unlock()
|
||||
c.wakeWorker()
|
||||
var once sync.Once
|
||||
return func() {
|
||||
once.Do(func() {
|
||||
c.activity.Lock()
|
||||
c.activity.foreground--
|
||||
if c.activity.foreground == 0 && c.activity.enabled && !c.activity.closed {
|
||||
c.activity.next = c.now().Add(maintenanceInterval)
|
||||
}
|
||||
c.activity.Unlock()
|
||||
c.wakeWorker()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// StartMaintenance enables the worker timer once, with an initial one-minute delay.
|
||||
func (c *Client) StartMaintenance() {
|
||||
c.activity.Lock()
|
||||
if !c.activity.enabled && !c.activity.closed {
|
||||
c.activity.enabled = true
|
||||
c.activity.generation++
|
||||
if c.activity.foreground == 0 {
|
||||
c.activity.next = c.now().Add(maintenanceInterval)
|
||||
}
|
||||
}
|
||||
c.activity.Unlock()
|
||||
c.wakeWorker()
|
||||
}
|
||||
|
||||
// StopMaintenance prevents new admissions and waits for an admitted attempt to exit.
|
||||
// An underlying serial read can finish only according to its existing read timeout.
|
||||
func (c *Client) StopMaintenance() {
|
||||
c.activity.Lock()
|
||||
c.activity.enabled = false
|
||||
c.activity.generation++
|
||||
c.activity.next = time.Time{}
|
||||
if c.activity.cancel != nil {
|
||||
c.activity.cancel()
|
||||
}
|
||||
finished := c.activity.finished
|
||||
c.activity.Unlock()
|
||||
c.wakeWorker()
|
||||
if finished != nil {
|
||||
<-finished
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) maintenanceDelay() (time.Duration, bool) {
|
||||
c.activity.Lock()
|
||||
defer c.activity.Unlock()
|
||||
if !c.activity.enabled || c.activity.closed || c.activity.foreground != 0 || c.activity.next.IsZero() {
|
||||
return 0, false
|
||||
}
|
||||
remaining := c.activity.next.Sub(c.now())
|
||||
if remaining < 0 {
|
||||
remaining = 0
|
||||
}
|
||||
return remaining, true
|
||||
}
|
||||
|
||||
func (c *Client) passiveGeneration() (uint64, bool) {
|
||||
c.activity.Lock()
|
||||
defer c.activity.Unlock()
|
||||
return c.activity.generation, c.activity.foreground == 0 && !c.activity.closed
|
||||
}
|
||||
|
||||
func (c *Client) admitPassive(ctx context.Context, generation uint64) bool {
|
||||
c.activity.Lock()
|
||||
defer c.activity.Unlock()
|
||||
return ctx.Err() == nil && !c.activity.closed && c.activity.foreground == 0 && c.activity.generation == generation
|
||||
}
|
||||
|
||||
func (c *Client) admitMaintenance(ctx context.Context, generation uint64) bool {
|
||||
c.activity.Lock()
|
||||
defer c.activity.Unlock()
|
||||
return ctx.Err() == nil && c.activity.enabled && !c.activity.closed && c.activity.foreground == 0 && c.activity.generation == generation
|
||||
}
|
||||
|
||||
// maintainIdleCard is called only by the serial worker, never through its queue.
|
||||
func (c *Client) maintainIdleCard() {
|
||||
c.activity.Lock()
|
||||
if !c.activity.enabled || c.activity.closed || c.activity.foreground != 0 || c.activity.next.IsZero() || c.now().Before(c.activity.next) {
|
||||
c.activity.Unlock()
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), maintenanceTimeout)
|
||||
generation := c.activity.generation
|
||||
finished := make(chan struct{})
|
||||
c.activity.cancel, c.activity.finished = cancel, finished
|
||||
c.activity.Unlock()
|
||||
defer func() {
|
||||
cancel()
|
||||
c.activity.Lock()
|
||||
c.activity.cancel, c.activity.finished = nil, nil
|
||||
if c.activity.enabled && !c.activity.closed && c.activity.foreground == 0 && c.activity.generation == generation {
|
||||
c.activity.next = c.now().Add(maintenanceInterval)
|
||||
}
|
||||
close(finished)
|
||||
c.activity.Unlock()
|
||||
}()
|
||||
if !c.admitMaintenance(ctx, generation) {
|
||||
return
|
||||
}
|
||||
// Quiet observation: do not publish stock callbacks or manufacture cached status.
|
||||
st, err := checkDispenserStatus(ctx, c.port)
|
||||
if err != nil {
|
||||
log.Debugf("idle dispenser maintenance AP: %v", err)
|
||||
return
|
||||
}
|
||||
class, err := classifyPreparationStatus(st)
|
||||
if err != nil {
|
||||
log.Debugf("idle dispenser maintenance unusable AP: %v", err)
|
||||
return
|
||||
}
|
||||
log.Debugf("idle dispenser maintenance AP; class=%s raw status: % X", class, st)
|
||||
if class == encoderConfirmed || class == positionWellEmpty {
|
||||
return
|
||||
}
|
||||
clear, err := deliveryClearance(st)
|
||||
if err != nil || !clear {
|
||||
return
|
||||
}
|
||||
if c.deliveryPending && c.now().Sub(c.deliveryStarted) < deliveryMinimumWait {
|
||||
return
|
||||
}
|
||||
// This is the admission boundary shared with foreground registration and stop.
|
||||
if !c.admitMaintenance(ctx, generation) {
|
||||
return
|
||||
}
|
||||
if c.deliveryPending {
|
||||
c.deliveryPending = false
|
||||
}
|
||||
err = cardToEncoderPosition(ctx, c.port)
|
||||
c.invalidateStatusCache()
|
||||
if err != nil {
|
||||
log.Warnf("idle dispenser maintenance FC7 dispatch: %v", err)
|
||||
return
|
||||
}
|
||||
log.Info("idle dispenser maintenance FC7 dispatched")
|
||||
}
|
||||
362
internal/dispenser/maintenance_test.go
Normal file
362
internal/dispenser/maintenance_test.go
Normal file
@ -0,0 +1,362 @@
|
||||
package dispenser
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"reflect"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func maintenanceClient(t *testing.T, p *scriptedTransport) (*Client, *time.Time) {
|
||||
t.Helper()
|
||||
transportAddress(t)
|
||||
now := time.Unix(100, 0)
|
||||
c := &Client{port: p, done: make(chan struct{}), activityWake: make(chan struct{}, 1), sequenceTiming: sequenceTiming{now: func() time.Time { return now }, wait: waitForSequence}}
|
||||
t.Cleanup(c.Close)
|
||||
return c, &now
|
||||
}
|
||||
|
||||
func TestMaintenanceIdleScheduling(t *testing.T) {
|
||||
c, now := maintenanceClient(t, &scriptedTransport{})
|
||||
c.StartMaintenance()
|
||||
first := c.activity.next
|
||||
c.StartMaintenance()
|
||||
if c.activity.next != first {
|
||||
t.Fatal("repeated start reset the timer")
|
||||
}
|
||||
*now = now.Add(59 * time.Second)
|
||||
c.maintainIdleCard()
|
||||
if len(c.port.(*scriptedTransport).writes) != 0 {
|
||||
t.Fatal("maintenance ran before one minute")
|
||||
}
|
||||
release1 := c.BeginForeground()
|
||||
release2 := c.BeginForeground()
|
||||
release1()
|
||||
if _, enabled := c.maintenanceDelay(); enabled {
|
||||
t.Fatal("timer enabled while another issuance active")
|
||||
}
|
||||
*now = now.Add(20 * time.Second)
|
||||
release2()
|
||||
release2()
|
||||
if delay, enabled := c.maintenanceDelay(); !enabled || delay != time.Minute {
|
||||
t.Fatalf("after last release delay=%v enabled=%t", delay, enabled)
|
||||
}
|
||||
c.StopMaintenance()
|
||||
if _, enabled := c.maintenanceDelay(); enabled {
|
||||
t.Fatal("stop left timer enabled")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMaintenanceUsesCommonEligibility(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
st []byte
|
||||
pending bool
|
||||
age time.Duration
|
||||
fc7 bool
|
||||
}{
|
||||
{"clear", status(0x30), false, 0, true},
|
||||
{"staging", status(0x34), true, 3 * time.Second, true},
|
||||
{"too early", status(0x30), true, time.Second, false},
|
||||
{"encoder", status(0x33), true, time.Minute, false},
|
||||
{"empty", status(0x38), false, 0, false},
|
||||
{"uncertain", status(0x35), false, 0, false},
|
||||
{"movement", []byte{0x31, 0x30, 0x30, 0x34}, true, time.Minute, false},
|
||||
{"invalid", status(0x40), true, time.Minute, false},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
p := &scriptedTransport{chunks: [][]byte{vendorACK, apReply(tc.st), vendorACK}}
|
||||
c, now := maintenanceClient(t, p)
|
||||
c.StartMaintenance()
|
||||
*now = now.Add(time.Minute)
|
||||
c.deliveryPending = tc.pending
|
||||
c.deliveryStarted = now.Add(-tc.age)
|
||||
callbacks := 0
|
||||
c.OnStockUpdate(func(string) { callbacks++ })
|
||||
c.maintainIdleCard()
|
||||
want := []string{"AP"}
|
||||
if tc.fc7 {
|
||||
want = append(want, "FC7")
|
||||
}
|
||||
if got := wireCommands(p); !reflect.DeepEqual(got, want) {
|
||||
t.Errorf("commands=%v want %v", got, want)
|
||||
}
|
||||
if callbacks != 0 {
|
||||
t.Errorf("maintenance published %d stock callbacks", callbacks)
|
||||
}
|
||||
if tc.pending && !tc.fc7 && !c.deliveryPending {
|
||||
t.Error("ineligible maintenance cleared pending")
|
||||
}
|
||||
if delay, enabled := c.maintenanceDelay(); !enabled || delay != time.Minute {
|
||||
t.Errorf("next maintenance delay=%v enabled=%t", delay, enabled)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMaintenanceForegroundBetweenAPAndFC7(t *testing.T) {
|
||||
p := &scriptedTransport{chunks: [][]byte{vendorACK, apReply(status(0x30)), vendorACK}}
|
||||
c, now := maintenanceClient(t, p)
|
||||
c.StartMaintenance()
|
||||
*now = now.Add(time.Minute)
|
||||
var release func()
|
||||
p.afterRead = func() {
|
||||
if len(p.chunks) == 1 && release == nil {
|
||||
release = c.BeginForeground()
|
||||
}
|
||||
}
|
||||
c.maintainIdleCard()
|
||||
if got := wireCommands(p); !reflect.DeepEqual(got, []string{"AP"}) {
|
||||
t.Errorf("foreground during AP commands=%v want AP only", got)
|
||||
}
|
||||
if release == nil {
|
||||
t.Fatal("barrier not reached")
|
||||
}
|
||||
release()
|
||||
if delay, _ := c.maintenanceDelay(); delay != time.Minute {
|
||||
t.Errorf("foreground completion delay=%v", delay)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMaintenanceAdmissionConditions(t *testing.T) {
|
||||
c, _ := maintenanceClient(t, &scriptedTransport{})
|
||||
c.StartMaintenance()
|
||||
generation := c.activity.generation
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
if !c.admitMaintenance(ctx, generation) {
|
||||
t.Fatal("idle admission rejected")
|
||||
}
|
||||
cancel()
|
||||
if c.admitMaintenance(ctx, generation) {
|
||||
t.Fatal("canceled admission accepted")
|
||||
}
|
||||
expired, stop := context.WithDeadline(context.Background(), time.Now().Add(-time.Second))
|
||||
defer stop()
|
||||
if c.admitMaintenance(expired, generation) {
|
||||
t.Fatal("expired admission accepted")
|
||||
}
|
||||
release := c.BeginForeground()
|
||||
if c.admitMaintenance(context.Background(), generation) {
|
||||
t.Fatal("foreground/stale admission accepted")
|
||||
}
|
||||
release()
|
||||
if c.admitMaintenance(context.Background(), generation) {
|
||||
t.Fatal("obsolete generation accepted")
|
||||
}
|
||||
generation = c.activity.generation
|
||||
c.StopMaintenance()
|
||||
if c.admitMaintenance(context.Background(), generation) {
|
||||
t.Fatal("stopped admission accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPassivePollGenerationAtDispatch(t *testing.T) {
|
||||
c, _ := maintenanceClient(t, &scriptedTransport{})
|
||||
generation, _ := c.passiveGeneration()
|
||||
release := c.BeginForeground()
|
||||
for _, active := range []bool{true, false} {
|
||||
if !active {
|
||||
release()
|
||||
}
|
||||
response := make(chan cmdResp, 1)
|
||||
c.handle(cmdReq{ctx: context.Background(), typ: cmdStatus, passive: true, generation: generation, respCh: response})
|
||||
<-response
|
||||
if len(c.port.(*scriptedTransport).writes) != 0 {
|
||||
t.Errorf("active=%t stale passive request touched port", active)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMaintenanceStopWaitsForAdmittedRead(t *testing.T) {
|
||||
p := &scriptedTransport{chunks: [][]byte{vendorACK, apReply(status(0x30)), vendorACK}}
|
||||
c, now := maintenanceClient(t, p)
|
||||
c.StartMaintenance()
|
||||
*now = now.Add(time.Minute)
|
||||
entered, resume := make(chan struct{}), make(chan struct{})
|
||||
var once sync.Once
|
||||
p.afterRead = func() { once.Do(func() { close(entered); <-resume }) }
|
||||
finished := make(chan struct{})
|
||||
go func() { c.maintainIdleCard(); close(finished) }()
|
||||
<-entered
|
||||
stopped := make(chan struct{})
|
||||
go func() { c.StopMaintenance(); close(stopped) }()
|
||||
// Wait until stop has disabled admission.
|
||||
for {
|
||||
c.activity.Lock()
|
||||
enabled := c.activity.enabled
|
||||
c.activity.Unlock()
|
||||
if !enabled {
|
||||
break
|
||||
}
|
||||
time.Sleep(time.Millisecond)
|
||||
}
|
||||
select {
|
||||
case <-stopped:
|
||||
t.Fatal("stop returned before admitted read finished")
|
||||
default:
|
||||
}
|
||||
close(resume)
|
||||
<-finished
|
||||
<-stopped
|
||||
if got := wireCommands(p); !reflect.DeepEqual(got, []string{"AP"}) {
|
||||
t.Errorf("stop commands=%v want AP only", got)
|
||||
}
|
||||
c.StopMaintenance()
|
||||
c.Close()
|
||||
c.Close()
|
||||
}
|
||||
|
||||
func TestMaintenanceWorkerTimerWake(t *testing.T) {
|
||||
c := NewClient(nil, 1)
|
||||
c.StartMaintenance()
|
||||
release := c.BeginForeground()
|
||||
// An immediate obsolete timer is harmless while foreground is registered.
|
||||
c.activity.Lock()
|
||||
c.activity.next = time.Now().Add(-time.Second)
|
||||
c.activity.Unlock()
|
||||
c.wakeWorker()
|
||||
release()
|
||||
if delay, enabled := c.maintenanceDelay(); !enabled || delay <= 59*time.Second {
|
||||
t.Errorf("worker schedule delay=%v enabled=%t", delay, enabled)
|
||||
}
|
||||
c.Close()
|
||||
if r := c.doResponse(context.Background(), cmdStatus); r.err == nil {
|
||||
t.Fatal("closed client accepted a request")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMaintenanceWorkerTimerDispatch(t *testing.T) {
|
||||
transportAddress(t)
|
||||
p := &scriptedTransport{chunks: [][]byte{vendorACK, apReply(status(0x30)), vendorACK}}
|
||||
c := &Client{port: p, reqCh: make(chan cmdReq, 1), done: make(chan struct{}), activityWake: make(chan struct{}, 1), sequenceTiming: sequenceTiming{now: time.Now, wait: waitForSequence}}
|
||||
c.StartMaintenance()
|
||||
c.activity.Lock()
|
||||
c.activity.next = time.Now().Add(-time.Second)
|
||||
c.activity.Unlock()
|
||||
workerDone := make(chan struct{})
|
||||
go func() { defer close(workerDone); c.loop() }()
|
||||
t.Cleanup(func() { c.Close(); <-workerDone })
|
||||
dispatched := make(chan struct{})
|
||||
// Observe completion through the activity guard, without touching the transport concurrently.
|
||||
go func() {
|
||||
for {
|
||||
c.activity.Lock()
|
||||
next := c.activity.next
|
||||
c.activity.Unlock()
|
||||
if time.Until(next) > 50*time.Second {
|
||||
close(dispatched)
|
||||
return
|
||||
}
|
||||
select {
|
||||
case <-c.done:
|
||||
return
|
||||
case <-time.After(time.Millisecond):
|
||||
}
|
||||
}
|
||||
}()
|
||||
select {
|
||||
case <-dispatched:
|
||||
case <-time.After(4 * time.Second):
|
||||
t.Fatal("worker timer did not finish maintenance")
|
||||
}
|
||||
c.StopMaintenance()
|
||||
if got := wireCommands(p); !reflect.DeepEqual(got, []string{"AP", "FC7"}) {
|
||||
t.Errorf("timer commands=%v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestForegroundEncoderClearanceCharacterization(t *testing.T) {
|
||||
for _, movement := range []bool{false, true} {
|
||||
st := status(0x33)
|
||||
if movement {
|
||||
st[0] = 0x31
|
||||
}
|
||||
p := &scriptedTransport{chunks: [][]byte{vendorACK, apReply(st), vendorACK, apReply(st), vendorACK, apReply(st), vendorACK}}
|
||||
c, now := maintenanceClient(t, p)
|
||||
c.deliveryPending = true
|
||||
c.deliveryStarted = now.Add(-time.Minute)
|
||||
c.sequenceTiming.wait = func(context.Context, time.Duration) error { *now = now.Add(2 * time.Second); return nil }
|
||||
r := workerRequest(c, context.Background(), cmdToEncoder)
|
||||
want := []string{"AP", "AP", "AP"}
|
||||
if movement {
|
||||
if !errors.Is(r.err, context.DeadlineExceeded) || !c.deliveryPending {
|
||||
t.Errorf("movement err=%v pending=%t", r.err, c.deliveryPending)
|
||||
}
|
||||
} else {
|
||||
want = append(want, "FC7")
|
||||
if r.err != nil || c.deliveryPending {
|
||||
t.Errorf("internal fallback err=%v pending=%t", r.err, c.deliveryPending)
|
||||
}
|
||||
}
|
||||
if got := wireCommands(p); !reflect.DeepEqual(got, want) {
|
||||
t.Errorf("movement=%t commands=%v want %v", movement, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestForegroundEncoderClearanceCallerDeadline(t *testing.T) {
|
||||
p := &scriptedTransport{chunks: [][]byte{vendorACK, apReply(status(0x33)), vendorACK, apReply(status(0x33)), vendorACK, apReply(status(0x33))}}
|
||||
c, now := maintenanceClient(t, p)
|
||||
c.deliveryPending = true
|
||||
c.deliveryStarted = *now
|
||||
start := *now
|
||||
samples := 0
|
||||
p.afterRead = func() {
|
||||
if len(p.chunks)%2 == 0 { // Only complete frame reads advance the observation clock.
|
||||
if len(p.chunks) == 4 || len(p.chunks) == 2 || len(p.chunks) == 0 {
|
||||
samples++
|
||||
*now = now.Add(time.Second)
|
||||
}
|
||||
}
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 4*time.Second)
|
||||
defer cancel()
|
||||
c.sequenceTiming.wait = func(context.Context, time.Duration) error {
|
||||
if samples == 3 {
|
||||
<-ctx.Done()
|
||||
return ctx.Err()
|
||||
}
|
||||
*now = now.Add(time.Second)
|
||||
return nil
|
||||
}
|
||||
r := workerRequest(c, ctx, cmdToEncoder)
|
||||
if !errors.Is(r.err, context.DeadlineExceeded) || !c.deliveryPending {
|
||||
t.Errorf("caller deadline err=%v pending=%t", r.err, c.deliveryPending)
|
||||
}
|
||||
if got := wireCommands(p); !reflect.DeepEqual(got, []string{"AP", "AP", "AP"}) {
|
||||
t.Errorf("caller deadline commands=%v want AP only", got)
|
||||
}
|
||||
if elapsed := now.Sub(start); elapsed != 5*time.Second {
|
||||
t.Errorf("last observation elapsed=%v want 5s", elapsed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMaintenanceFailuresAreQuietAndRetryLater(t *testing.T) {
|
||||
for _, mechanical := range []bool{false, true} {
|
||||
p := &scriptedTransport{chunks: [][]byte{{0x10, 0x06, 0x30}}}
|
||||
if mechanical {
|
||||
p.chunks = [][]byte{vendorACK, apReply(status(0x30))}
|
||||
}
|
||||
c, now := maintenanceClient(t, p)
|
||||
c.StartMaintenance()
|
||||
*now = now.Add(time.Minute)
|
||||
callbacks := 0
|
||||
c.OnStockUpdate(func(string) { callbacks++ })
|
||||
c.maintainIdleCard()
|
||||
want := []string{"AP"}
|
||||
if mechanical {
|
||||
want = append(want, "FC7")
|
||||
}
|
||||
if got := wireCommands(p); !reflect.DeepEqual(got, want) {
|
||||
t.Errorf("mechanical=%t commands=%v want %v", mechanical, got, want)
|
||||
}
|
||||
if callbacks != 0 {
|
||||
t.Errorf("maintenance failure callbacks=%d", callbacks)
|
||||
}
|
||||
if delay, enabled := c.maintenanceDelay(); !enabled || delay != time.Minute {
|
||||
t.Errorf("retry delay=%v enabled=%t", delay, enabled)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -25,15 +25,22 @@ type dispenserCallResult struct {
|
||||
}
|
||||
|
||||
type fakeDoorCardDispenser struct {
|
||||
prepareCurrent dispenserCallResult
|
||||
deliverCurrent dispenserCallResult
|
||||
beginNextErr error
|
||||
beginNext func() error
|
||||
prepareNext dispenserCallResult
|
||||
calls []string
|
||||
prepareCurrent dispenserCallResult
|
||||
deliverCurrent dispenserCallResult
|
||||
beginNextErr error
|
||||
beginNext func() error
|
||||
prepareNext dispenserCallResult
|
||||
activity int
|
||||
registrations int
|
||||
releases int
|
||||
outsideActivity bool
|
||||
calls []string
|
||||
}
|
||||
|
||||
func (d *fakeDoorCardDispenser) PrepareCurrentCard(context.Context) (string, error) {
|
||||
if d.activity == 0 {
|
||||
d.outsideActivity = true
|
||||
}
|
||||
d.calls = append(d.calls, "prepare current")
|
||||
return d.prepareCurrent.status, d.prepareCurrent.err
|
||||
}
|
||||
@ -377,3 +384,28 @@ func TestIssueDoorCardOnlyEmptyPreparationReturns503(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (d *fakeDoorCardDispenser) BeginForeground() func() {
|
||||
d.activity++
|
||||
d.registrations++
|
||||
return func() { d.activity--; d.releases++ }
|
||||
}
|
||||
|
||||
func TestDoorCardForegroundLifecycle(t *testing.T) {
|
||||
for _, testEndpoint := range []bool{false, true} {
|
||||
d := &fakeDoorCardDispenser{prepareCurrent: dispenserCallResult{err: errors.New("stop before encoding")}}
|
||||
lock := &fakeDoorCardLockServer{}
|
||||
_, _, app := performIssueDoorCardRequest(t, d, lock)
|
||||
if d.registrations != 1 || d.releases != 1 || d.activity != 0 || d.outsideActivity {
|
||||
t.Fatalf("issue registration=%d release=%d active=%d outside=%t", d.registrations, d.releases, d.activity, d.outsideActivity)
|
||||
}
|
||||
if testEndpoint {
|
||||
req := httptest.NewRequest(http.MethodPost, "/testissuedoorcard", strings.NewReader("{}"))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
app.testIssueDoorCard(httptest.NewRecorder(), req)
|
||||
if d.registrations != 2 || d.releases != 2 || d.activity != 0 || d.outsideActivity {
|
||||
t.Errorf("test endpoint registration=%d release=%d active=%d outside=%t", d.registrations, d.releases, d.activity, d.outsideActivity)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -27,6 +27,7 @@ import (
|
||||
)
|
||||
|
||||
type doorCardDispenser interface {
|
||||
BeginForeground() func()
|
||||
PrepareCurrentCard(context.Context) (string, error)
|
||||
DeliverCurrentCard(context.Context) (string, error)
|
||||
BeginPrepareNextCard(context.Context) error
|
||||
@ -290,6 +291,9 @@ func (app *App) issueDoorCard(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
release := app.disp.BeginForeground()
|
||||
defer release()
|
||||
|
||||
status, err := app.disp.PrepareCurrentCard(r.Context())
|
||||
app.SetCardWellStatus(status)
|
||||
if err != nil {
|
||||
|
||||
@ -59,6 +59,9 @@ func (app *App) testIssueDoorCard(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// Ensure dispenser ready (card at encoder) BEFORE we attempt encoding.
|
||||
// With queued dispenser ops, this will not clash with polling.
|
||||
release := app.disp.BeginForeground()
|
||||
defer release()
|
||||
|
||||
status, err := app.disp.PrepareCurrentCard(r.Context())
|
||||
app.SetCardWellStatus(status)
|
||||
if err != nil {
|
||||
|
||||
@ -2,6 +2,9 @@
|
||||
|
||||
builtVersion is a const in main.go
|
||||
|
||||
#### v2.1.1 - 28 September 2026
|
||||
feat(dispenser): add worker-owned idle card prestaging
|
||||
|
||||
#### v2.1.0 - 28 September 2026
|
||||
fix(dispenser): tolerate unusable AP observations during card preparation
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user