Fix nginx trailing-slash mixed-content redirect, add self-healing device reclaim
- location /tablet-checkin/api/devices/ (trailing slash, proxy_pass) made nginx auto-redirect the bare /api/devices list endpoint, and since this inner nginx sits behind NPM's TLS termination it built that redirect as http:// even over an https:// request — browsers block that as mixed content, surfacing as a bare 'Failed to fetch' on the Devices admin page with no other clue. Dropped the trailing slash on both the location pattern and proxy_pass target; still correctly prefix-matches every /devices/:id/... sub-route, no more auto-redirect at all. - pushNavigate/send() now only reports delivered:true if a write demonstrably succeeded, not just because a stream entry exists for the device — a stale-but-not-formally-closed connection can silently accept a write that never reaches the far end. - pushBookingToDevice can now self-heal a device stuck 'busy' with no live session behind it (session already expired unused) rather than requiring an admin to manually click Reset first. Real failure mode hit testing on a physical tablet 2026-09-01: two pushes worked end-to-end, a third's navigate event never reached the tablet (SSE connection silently died during an idle gap), leaving the device stuck with no way to recover except a manual admin reset. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
28d3db8039
commit
f4625e0860
3 changed files with 54 additions and 6 deletions
|
|
@ -17,14 +17,26 @@ export function removeStream(deviceId, res) {
|
||||||
if (streams.get(deviceId)?.size === 0) streams.delete(deviceId)
|
if (streams.get(deviceId)?.size === 0) streams.delete(deviceId)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Returns true only if at least one write demonstrably succeeded (didn't
|
||||||
|
// throw) — NOT just "a stream entry exists for this device". A stale-but-
|
||||||
|
// not-yet-closed connection (tablet's WiFi silently drops mid-idle, common
|
||||||
|
// enough to have caused a real stuck-busy device 2026-09-01 — see
|
||||||
|
// project_checkin memory) can accept a write() without throwing even though
|
||||||
|
// nothing will ever actually reach the far end, so "an entry exists" was
|
||||||
|
// never a reliable signal on its own. This doesn't catch every dead-socket
|
||||||
|
// case (a true network black hole can still succeed the write locally), but
|
||||||
|
// it does catch the more common "connection already formally closed"
|
||||||
|
// case, which is worth surfacing honestly rather than reporting delivered
|
||||||
|
// regardless.
|
||||||
function send(deviceId, event, data) {
|
function send(deviceId, event, data) {
|
||||||
const set = streams.get(deviceId)
|
const set = streams.get(deviceId)
|
||||||
if (!set) return false
|
if (!set || !set.size) return false
|
||||||
const payload = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`
|
const payload = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`
|
||||||
|
let wroteAny = false
|
||||||
for (const res of set) {
|
for (const res of set) {
|
||||||
try { res.write(payload) } catch { /* dead connection, will be cleaned up on close */ }
|
try { res.write(payload); wroteAny = true } catch { /* dead connection, will be cleaned up on close */ }
|
||||||
}
|
}
|
||||||
return true
|
return wroteAny
|
||||||
}
|
}
|
||||||
|
|
||||||
// Returns true if the device had a live connection to push to. staff.js still
|
// Returns true if the device had a live connection to push to. staff.js still
|
||||||
|
|
|
||||||
|
|
@ -16,10 +16,34 @@ export async function pushBookingToDevice(deviceId, bookingId) {
|
||||||
// truth, not the SELECT above (which is only used for a friendly device
|
// truth, not the SELECT above (which is only used for a friendly device
|
||||||
// name in the 409 message), so two near-simultaneous pushes to the same
|
// name in the 409 message), so two near-simultaneous pushes to the same
|
||||||
// device can't both "win".
|
// device can't both "win".
|
||||||
const { rows: claimed } = await pool.query(
|
let { rows: claimed } = await pool.query(
|
||||||
`UPDATE devices SET status = 'busy' WHERE device_id = $1 AND status = 'idle' RETURNING *`,
|
`UPDATE devices SET status = 'busy' WHERE device_id = $1 AND status = 'idle' RETURNING *`,
|
||||||
[deviceId]
|
[deviceId]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if (!claimed.length) {
|
||||||
|
// Self-healing fallback: a device can get stuck 'busy' with no live
|
||||||
|
// session behind it — e.g. the tablet's connection silently drops
|
||||||
|
// mid-push and the navigate event never actually reaches it (a real
|
||||||
|
// failure hit 2026-09-01, see project_checkin memory). Previously that
|
||||||
|
// needed an admin to manually click Reset before the device could be
|
||||||
|
// used again at all. Reclaim it here instead, still atomically (only
|
||||||
|
// succeeds if the device is 'busy' with no unexpired *unused* session
|
||||||
|
// at the exact moment of the UPDATE) — a genuinely in-progress guest
|
||||||
|
// check-in, still inside its session TTL, correctly keeps blocking a
|
||||||
|
// steal.
|
||||||
|
;({ rows: claimed } = await pool.query(
|
||||||
|
`UPDATE devices SET status = 'busy'
|
||||||
|
WHERE device_id = $1 AND status = 'busy'
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM checkin_sessions
|
||||||
|
WHERE device_id = $1 AND used_at IS NULL AND expires_at > now()
|
||||||
|
)
|
||||||
|
RETURNING *`,
|
||||||
|
[deviceId]
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
if (!claimed.length) {
|
if (!claimed.length) {
|
||||||
return { ok: false, status: 409, error: `${existing[0].name} is busy with another check-in` }
|
return { ok: false, status: 409, error: `${existing[0].name} is busy with another check-in` }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -14,8 +14,20 @@ server {
|
||||||
# SSE (devices/:id/stream) needs buffering off and no timeout — otherwise
|
# SSE (devices/:id/stream) needs buffering off and no timeout — otherwise
|
||||||
# nginx buffers the event-stream response and the tablet's idle page
|
# nginx buffers the event-stream response and the tablet's idle page
|
||||||
# never actually receives a push in real time.
|
# never actually receives a push in real time.
|
||||||
location /tablet-checkin/api/devices/ {
|
#
|
||||||
proxy_pass http://backend:3001/api/devices/;
|
# No trailing slash on either the location pattern or the proxy_pass
|
||||||
|
# target — with one, nginx auto-redirects any request matching this
|
||||||
|
# prefix *without* the trailing slash (i.e. exactly
|
||||||
|
# /tablet-checkin/api/devices, the plain device-list endpoint) to add
|
||||||
|
# it. That redirect broke real usage 2026-09-01: this inner nginx sits
|
||||||
|
# behind NPM's TLS termination, forwarded as plain HTTP, so it built
|
||||||
|
# the redirect's Location header as http:// even though the original
|
||||||
|
# request was https:// — browsers block that as mixed content, which
|
||||||
|
# surfaced as a bare "Failed to fetch" with no other clue. Dropping the
|
||||||
|
# trailing slash keeps this a plain prefix match (still correctly
|
||||||
|
# covers every /devices/:id/... sub-route) with no auto-redirect at all.
|
||||||
|
location /tablet-checkin/api/devices {
|
||||||
|
proxy_pass http://backend:3001/api/devices;
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue