From bf80dbe089e4f2c32954c3ea4af46e6bec971f90 Mon Sep 17 00:00:00 2001 From: Rich Thanki Date: Mon, 8 Jun 2026 04:55:02 +0000 Subject: [PATCH 01/17] restores reactor discipline removes uart hacks --- services/fabric/fabric.go | 9 + services/fabric/fabric_test.go | 68 ++-- services/fabric/session.go | 118 +------ services/fabric/transfer.go | 132 +++++-- services/fabric/transfer_test.go | 7 +- services/hal/devices/serial_raw/builder.go | 33 -- utilities/jsonw.go | 172 +++++----- utilities/logger.go | 382 ++++++++++----------- 8 files changed, 431 insertions(+), 490 deletions(-) diff --git a/services/fabric/fabric.go b/services/fabric/fabric.go index 3a62b7e..8693a32 100644 --- a/services/fabric/fabric.go +++ b/services/fabric/fabric.go @@ -43,6 +43,11 @@ type LinkConfig struct { // this window once established. Mirrors session_ctl.lua's // liveness_timeout_s. Release: 30s. LivenessTimeout time.Duration + // TargetCallTimeout is the local updater/main stage RPC deadline after + // xfer_commit has verified the wire transfer. The Fabric session owns this + // as pending operation state; it must not block the reactor loop. + // Release: 5s. + TargetCallTimeout time.Duration // MaxInboundHelpers caps the number of in-flight inbound RPC calls. // Excess inbound calls reply `{ok=false, err="busy"}` per // rpc_bridge.lua's `spawn_local_call_helper`. Lua default is 64 @@ -61,6 +66,7 @@ func DefaultLinkConfig() LinkConfig { PhaseTimeout: 15 * time.Second, PingInterval: 10 * time.Second, LivenessTimeout: 30 * time.Second, + TargetCallTimeout: 5 * time.Second, MaxInboundHelpers: 64, RPCQuantum: 4, BulkQuantum: 1, @@ -81,6 +87,9 @@ func (c *LinkConfig) applyDefaults() { if c.LivenessTimeout == 0 { c.LivenessTimeout = d.LivenessTimeout } + if c.TargetCallTimeout == 0 { + c.TargetCallTimeout = d.TargetCallTimeout + } if c.MaxInboundHelpers == 0 { c.MaxInboundHelpers = d.MaxInboundHelpers } diff --git a/services/fabric/fabric_test.go b/services/fabric/fabric_test.go index b326e4c..5c50472 100644 --- a/services/fabric/fabric_test.go +++ b/services/fabric/fabric_test.go @@ -1286,7 +1286,7 @@ func TestDrainExportsPausesDuringIncomingTransfer(t *testing.T) { } } -func TestDrainExportsPausesAfterPrepareCall(t *testing.T) { +func TestDrainExportsContinuesAfterPrepareCall(t *testing.T) { b := newBus() fabricConn := b.NewConnection("fabric") pubConn := b.NewConnection("publisher") @@ -1332,32 +1332,22 @@ func TestDrainExportsPausesAfterPrepareCall(t *testing.T) { )) s.drainExports() - if len(tr.writes) != 0 { - t.Fatalf("writes during prepare quiet = %d, want 0", len(tr.writes)) - } - - s.transferQuietUntil = time.Time{} - s.transferQuietReason = "" - s.drainExports() - if len(tr.writes) != 1 { - t.Fatalf("writes after prepare quiet = %d, want 1", len(tr.writes)) + t.Fatalf("writes after prepare call = %d, want 1", len(tr.writes)) } } -func TestDrainExportsAllowsOnlyCriticalFactsDuringPostTransferQuiet(t *testing.T) { +func TestDrainExportsDoesNotUsePostTransferQuietWindow(t *testing.T) { b := bus.NewBus(16, "+", "#") fabricConn := b.NewConnection("fabric") pubConn := b.NewConnection("publisher") tr := &captureTransport{} s := session{ - conn: fabricConn, - tr: tr, - link: linkUp, - exportsEnabled: true, - exportReadyAt: time.Now().Add(-time.Second), - transferQuietUntil: time.Now().Add(time.Second), - transferQuietReason: "xfer_done", + conn: fabricConn, + tr: tr, + link: linkUp, + exportsEnabled: true, + exportReadyAt: time.Now().Add(-time.Second), } s.setupExports() @@ -1387,8 +1377,8 @@ func TestDrainExportsAllowsOnlyCriticalFactsDuringPostTransferQuiet(t *testing.T for i := 0; i < len(criticalExportTopics)+4; i++ { s.drainExports() } - if len(tr.writes) != len(criticalExportTopics) { - t.Fatalf("writes during post-transfer quiet = %d, want %d critical facts", + if len(tr.writes) < len(criticalExportTopics) { + t.Fatalf("writes after transfer = %d, want at least %d critical facts", len(tr.writes), len(criticalExportTopics)) } want := [][]string{ @@ -1837,21 +1827,19 @@ func TestPongAllowedDuringIncomingTransfer(t *testing.T) { } } -func TestPongAllowedDuringPrepareQuietForEstablishedPeer(t *testing.T) { +func TestPongAllowedForEstablishedPeerWithoutQuietWindow(t *testing.T) { tr := &captureTransport{} s := session{ - tr: tr, - link: linkUp, - localSID: "mcu-sid-test", - peerSID: "cm5-sid", - transferQuietUntil: time.Now().Add(time.Second), - transferQuietReason: "prepare_call_rx", + tr: tr, + link: linkUp, + localSID: "mcu-sid-test", + peerSID: "cm5-sid", } s.onPing(&protoPing{Type: msgPing, SID: "cm5-sid"}) if len(tr.writes) != 1 { - t.Fatalf("pong writes during prepare quiet = %d, want 1", len(tr.writes)) + t.Fatalf("pong writes = %d, want 1", len(tr.writes)) } var pong protoPong if err := json.Unmarshal(tr.writes[0], &pong); err != nil { @@ -1862,15 +1850,13 @@ func TestPongAllowedDuringPrepareQuietForEstablishedPeer(t *testing.T) { } } -func TestPongRejectsWrongSIDDuringPrepareQuiet(t *testing.T) { +func TestPongRejectsWrongSIDWithoutQuietWindow(t *testing.T) { tr := &captureTransport{} s := session{ - tr: tr, - link: linkUp, - localSID: "mcu-sid-test", - peerSID: "cm5-sid", - transferQuietUntil: time.Now().Add(time.Second), - transferQuietReason: "prepare_call_rx", + tr: tr, + link: linkUp, + localSID: "mcu-sid-test", + peerSID: "cm5-sid", } s.onPing(&protoPing{Type: msgPing, SID: "other-sid"}) @@ -1910,15 +1896,13 @@ func TestWrongSIDPingPongDoNotRefreshLiveness(t *testing.T) { } } -func TestPongRejectsSelfSIDDuringPrepareQuiet(t *testing.T) { +func TestPongRejectsSelfSIDWithoutQuietWindow(t *testing.T) { tr := &captureTransport{} s := session{ - tr: tr, - link: linkUp, - localSID: "mcu-sid-test", - peerSID: "mcu-sid-test", - transferQuietUntil: time.Now().Add(time.Second), - transferQuietReason: "prepare_call_rx", + tr: tr, + link: linkUp, + localSID: "mcu-sid-test", + peerSID: "mcu-sid-test", } s.onPing(&protoPing{Type: msgPing, SID: "mcu-sid-test"}) diff --git a/services/fabric/session.go b/services/fabric/session.go index 133ba30..f3f8920 100644 --- a/services/fabric/session.go +++ b/services/fabric/session.go @@ -52,20 +52,6 @@ const ( exportMaxPerTick = 1 exportTickInterval = 50 * time.Millisecond errPayloadMarshal = "payload_marshal_failed" - - // Temporary transport recovery policy: the USB/UART path used during OTA - // can echo MCU-originated JSONL back into the MCU receiver. If exported - // retained state is in flight while CM5 starts an OTA transfer, the echoed - // line can contain CM5's xfer_begin spliced into the middle of the state pub. - // Hold exports quiet from prepare until either xfer_begin arrives or this - // window expires. Revisit after CM5 update-admission hardening so this does - // not become OTA semantics. - transferPrepareQuiet = 10 * time.Second - // Temporary transport recovery policy: keep telemetry/state exports quiet - // long enough for the host to send the follow-up updater commit call after - // xfer_done. On echo-prone UART links, retained export backlog can otherwise - // splice into the commit JSONL frame. - transferCompleteQuiet = 10 * time.Second ) // ---- link reasons and error strings ---- @@ -86,13 +72,12 @@ const ( // ---- types ---- type inboundCall struct { - id string - topic []string - localTopic bus.Topic - payload json.RawMessage - sub *bus.Subscription - deadline time.Time - transferPrepare bool + id string + topic []string + localTopic bus.Topic + payload json.RawMessage + sub *bus.Subscription + deadline time.Time } type outboundCall struct { @@ -164,8 +149,7 @@ type session struct { rpcReady bool // bridge replay complete; gates linkStatePayload.Ready incomingTransfer *incomingTransfer completedTransfers []completedTransfer - transferQuietUntil time.Time - transferQuietReason string + pendingTargetCall *pendingTargetCall beginTransfer func(transferMeta) (transferSink, error) } @@ -245,6 +229,7 @@ func (s *session) run(ctx context.Context) { defer s.teardownExports() defer s.teardownInbound() defer s.teardownOutbound(reasonLinkDown) + defer s.cancelTargetCall(reasonLinkDown) defer s.abortTransfer(reasonLinkDown) defer s.log("run stop") @@ -288,6 +273,7 @@ func (s *session) run(ctx context.Context) { s.drainInbound(now) s.drainOutbound(now) s.checkTransferTimeout(now) + s.drainTargetCall(now) s.tickPing(now) s.tickReady(now) @@ -388,12 +374,11 @@ func (s *session) handleLinkDown(reason, err string) { s.exportReadyAt = time.Time{} s.exportsEnabled = false s.rpcReady = false - s.transferQuietUntil = time.Time{} - s.transferQuietReason = "" s.teardownExports() s.teardownInbound() s.teardownOutbound(pendingReason) s.teardownImportedRetained() + s.cancelTargetCall(pendingReason) s.abortTransfer(pendingReason) s.clearCompletedTransfers() s.publishLinkState(reason, err) @@ -420,6 +405,7 @@ func (s *session) promoteLink(reason string) { if reason == "" { reason = reasonPeerReset } + s.cancelTargetCall(reason) s.abortTransfer(reason) s.teardownExports() s.teardownInbound() @@ -805,38 +791,6 @@ func validWireTopic(topic []string) bool { return true } -func (s *session) extendTransferQuiet(reason string, d time.Duration) { - now := time.Now() - until := now.Add(d) - if until.After(s.transferQuietUntil) { - s.transferQuietUntil = until - s.transferQuietReason = reason - } -} - -func (s *session) transferQuiet(now time.Time) (bool, string) { - if cur := s.incomingTransfer; cur != nil { - return true, "incoming_transfer:" + cur.meta.ID - } - if !s.transferQuietUntil.IsZero() && now.Before(s.transferQuietUntil) { - reason := s.transferQuietReason - if reason == "" { - reason = "quiet_window" - } - return true, reason - } - return false, "" -} - -func quietAllowsCriticalExports(reason string) bool { - switch reason { - case "xfer_commit_target", "xfer_target_rejected", "xfer_done": - return true - default: - return false - } -} - func (s *session) onHello(msg *protoHello) { if msg.Proto != protocolName { s.log("hello dropped: unsupported proto") @@ -916,13 +870,6 @@ func (s *session) tickPing(now time.Time) { if s.link != linkUp { return } - if quiet, _ := s.transferQuiet(now); quiet { - // Keep the UART quiet while CM5 is preparing or streaming a firmware - // image; chunk recovery depends on xfer_need being the only periodic - // MCU-originated frame on the fabric link. - s.nextPingAt = now.Add(s.cfg.PingInterval) - return - } if s.nextPingAt.IsZero() || now.Before(s.nextPingAt) { return } @@ -1026,11 +973,6 @@ func (s *session) onCall(msg *protoCall) { s.rpcDiag("call_route_ok", msg, localTopic, "") s.markRx() - isTransferPrepare := wireTopicEquals(msg.Topic, wireUpdaterPrepare) - if isTransferPrepare { - s.extendTransferQuiet("prepare_call_rx", transferPrepareQuiet) - } - timeout := callTimeoutDef if msg.TimeoutMs > 0 { timeout = time.Duration(msg.TimeoutMs) * time.Millisecond @@ -1042,13 +984,12 @@ func (s *session) onCall(msg *protoCall) { sub := s.conn.Request(busMsg) topicCopy := append([]string(nil), msg.Topic...) s.inboundCalls = append(s.inboundCalls, &inboundCall{ - id: msg.ID, - topic: topicCopy, - localTopic: localTopic, - payload: append(json.RawMessage(nil), msg.Payload...), - sub: sub, - deadline: time.Now().Add(timeout), - transferPrepare: isTransferPrepare, + id: msg.ID, + topic: topicCopy, + localTopic: localTopic, + payload: append(json.RawMessage(nil), msg.Payload...), + sub: sub, + deadline: time.Now().Add(timeout), }) } @@ -1275,26 +1216,16 @@ func (s *session) drainExports() { return } now := time.Now() - quiet, quietReason := s.transferQuiet(now) if !s.exportsEnabled { return } if !s.exportReadyAt.IsZero() && now.Before(s.exportReadyAt) { return } - if quiet && !quietAllowsCriticalExports(quietReason) { - // Avoid colliding telemetry/state exports with prepare/xfer traffic on - // echo-prone links. Post-transfer quiet allows critical facts below so - // state=rebooting can reach CM5 before the reboot arm. - return - } total := 0 if !s.drainCriticalExports(&total) { return } - if quiet { - return - } if !s.criticalExportReplayDrained() { return } @@ -1338,9 +1269,6 @@ func (s *session) drainInbound(now time.Time) { s.conn.Unsubscribe(call.sub) call.sub = nil // prevent double-unsubscribe in teardownInbound if !ok || reply == nil { - if call.transferPrepare { - s.extendTransferQuiet("prepare_reply_timeout", transferPrepareQuiet) - } sent := s.sendRPC(marshal(protoReply{Type: msgReply, Corr: call.id, OK: false, Err: reasonTimeout})) s.rpcDiagInbound("call_reply_tx", call, false, reasonTimeout, otadiag.KV("sent", sent)) if !sent { @@ -1349,9 +1277,6 @@ func (s *session) drainInbound(now time.Time) { continue } if errStr := checkBusError(reply.Payload); errStr != "" { - if call.transferPrepare { - s.extendTransferQuiet("prepare_reply_error", transferPrepareQuiet) - } sent := s.sendRPC(marshal(protoReply{Type: msgReply, Corr: call.id, OK: false, Err: errStr})) s.rpcDiagInbound("call_reply_tx", call, false, errStr, otadiag.KV("sent", sent)) if !sent { @@ -1361,9 +1286,6 @@ func (s *session) drainInbound(now time.Time) { } payload, err := marshalPayload(reply.Payload) if err != nil { - if call.transferPrepare { - s.extendTransferQuiet("prepare_reply_marshal_failed", transferPrepareQuiet) - } sent := s.sendRPC(marshal(protoReply{Type: msgReply, Corr: call.id, OK: false, Err: errPayloadMarshal})) s.rpcDiagInbound("call_reply_tx", call, false, errPayloadMarshal, otadiag.KV("sent", sent)) if !sent { @@ -1371,9 +1293,6 @@ func (s *session) drainInbound(now time.Time) { } continue } - if call.transferPrepare { - s.extendTransferQuiet("prepare_reply_ok", transferPrepareQuiet) - } sent := s.sendRPC(marshal(protoReply{Type: msgReply, Corr: call.id, OK: true, Payload: payload})) s.rpcDiagInbound("call_reply_tx", call, true, "", otadiag.KV("sent", sent)) if !sent { @@ -1386,9 +1305,6 @@ func (s *session) drainInbound(now time.Time) { if !now.Before(call.deadline) { s.conn.Unsubscribe(call.sub) call.sub = nil - if call.transferPrepare { - s.extendTransferQuiet("prepare_call_timeout", transferPrepareQuiet) - } sent := s.sendRPC(marshal(protoReply{Type: msgReply, Corr: call.id, OK: false, Err: reasonTimeout})) s.rpcDiagInbound("call_reply_tx", call, false, reasonTimeout, otadiag.KV("sent", sent)) if !sent { diff --git a/services/fabric/transfer.go b/services/fabric/transfer.go index 95b10a7..2a71a0c 100644 --- a/services/fabric/transfer.go +++ b/services/fabric/transfer.go @@ -7,6 +7,7 @@ import ( "strings" "time" + "devicecode-go/bus" "devicecode-go/services/otadiag" "devicecode-go/services/updater" "devicecode-go/x/strconvx" @@ -77,6 +78,14 @@ type completedTransfer struct { meta transferMeta } +type pendingTargetCall struct { + xferID string + meta transferMeta + info transferInfo + sub *bus.Subscription + deadline time.Time +} + func sameTransferTuple(a, b transferMeta) bool { return a.ID == b.ID && a.Target == b.Target && @@ -284,7 +293,6 @@ func validateTransferBegin(msg *protoXferBegin) (transferMeta, string) { } func (s *session) onTransferBegin(msg *protoXferBegin) { - s.extendTransferQuiet("xfer_begin_rx", transferPrepareQuiet) otadiag.SetActiveXfer(msg.XferID) otadiag.Event( "[fabric-xfer]", "begin_rx", msg.XferID, @@ -347,6 +355,18 @@ func (s *session) onTransferBegin(msg *protoXferBegin) { otadiag.StopUpdateWindow("begin_reject") return } + if s.pendingTargetCall != nil { + reason := "busy" + abortOK := s.sendTransferAbort(meta.ID, reason) + otadiag.Event( + "[fabric-xfer]", "begin_reject", meta.ID, + otadiag.KV("reason", reason), + otadiag.KV("pending_xfer", s.pendingTargetCall.xferID), + otadiag.KV("abort_tx", abortOK), + ) + otadiag.StopUpdateWindow("begin_reject") + return + } if done, ok := s.completedTransferFor(meta.ID); ok { if sameTransferTuple(done, meta) { doneOK := s.sendTransferDone(meta.ID) @@ -620,33 +640,25 @@ func (s *session) onTransferCommit(msg *protoXferCommit) { } sink := cur.sink meta := cur.meta - s.extendTransferQuiet("xfer_commit_target", transferCompleteQuiet) s.clearTransfer() - bytesPayload := sink.Bytes() - ok, reason := s.invokeTransferTarget(meta, id, info, bytesPayload) - if !ok { - s.extendTransferQuiet("xfer_target_rejected", transferCompleteQuiet) + if reason := s.startTransferTargetCall(meta, id, info, sink.Bytes()); reason != "" { abortOK := s.sendTransferAbort(id, reason) otadiag.Event("[fabric-xfer]", "abort_tx", id, otadiag.KV("reason", reason), otadiag.KV("ok", abortOK)) return } - s.extendTransferQuiet("xfer_done", transferCompleteQuiet) - s.recordCompletedTransfer(meta) - doneOK := s.sendTransferDone(id) - otadiag.Event("[fabric-xfer]", "done_tx", id, otadiag.KV("ok", doneOK)) - otadiag.StopUpdateWindow("transfer_done") } -var targetCallTimeout = 5 * time.Second - -// invokeTransferTarget calls the local updater staging RPC named by -// xfer_begin.target. The wire no longer carries raw/member receiver topics; -// target="updater/main" maps to an internal bus RPC owned by the updater -// service. The reply gates whether fabric sends xfer_done or xfer_abort. -func (s *session) invokeTransferTarget(meta transferMeta, xferID string, info transferInfo, artefact []byte) (bool, string) { +// startTransferTargetCall invokes the local updater/main staging RPC without +// blocking the Fabric session reactor. The reply is observed by drainTargetCall +// on the normal session tick path; until then the session continues to process +// pings, exports, replies and link events. +func (s *session) startTransferTargetCall(meta transferMeta, xferID string, info transferInfo, artefact []byte) string { if meta.Target != transferTargetUpdaterMain { - return false, "unsupported_target" + return "unsupported_target" + } + if s.pendingTargetCall != nil { + return "busy" } payload := updater.StagePayload{ LinkID: s.linkID, @@ -660,25 +672,79 @@ func (s *session) invokeTransferTarget(meta transferMeta, xferID string, info tr Artefact: artefact, } msg := s.conn.NewMessage(updater.TopicStageRPC, payload, false) - replySub := s.conn.Request(msg) - defer s.conn.Unsubscribe(replySub) + s.pendingTargetCall = &pendingTargetCall{ + xferID: xferID, + meta: meta, + info: info, + sub: s.conn.Request(msg), + deadline: time.Now().Add(s.cfg.TargetCallTimeout), + } + otadiag.Event("[fabric-xfer]", "target_call_start", xferID, + otadiag.KV("timeout_ms", int(s.cfg.TargetCallTimeout/time.Millisecond)), + ) + return "" +} + +func (s *session) finishTargetCall(call *pendingTargetCall, ok bool, reason string) { + if call == nil { + return + } + if call.sub != nil { + s.conn.Unsubscribe(call.sub) + call.sub = nil + } + s.pendingTargetCall = nil + if ok { + s.recordCompletedTransfer(call.meta) + doneOK := s.sendTransferDone(call.xferID) + otadiag.Event("[fabric-xfer]", "done_tx", call.xferID, otadiag.KV("ok", doneOK)) + otadiag.StopUpdateWindow("transfer_done") + return + } + if reason == "" { + reason = "stage_rejected" + } + updater.CancelStreamedStage(call.xferID, call.info.Generation, reason) + abortOK := s.sendTransferAbort(call.xferID, reason) + otadiag.Event("[fabric-xfer]", "abort_tx", call.xferID, otadiag.KV("reason", reason), otadiag.KV("ok", abortOK)) +} +func (s *session) drainTargetCall(now time.Time) { + call := s.pendingTargetCall + if call == nil { + return + } select { - case rep, ok := <-replySub.Channel(): + case rep, ok := <-call.sub.Channel(): if !ok || rep == nil { - updater.CancelStreamedStage(xferID, info.Generation, "stage_no_reply") - return false, "stage_no_reply" - } - ok, reason := decodeStageReply(rep.Payload) - if !ok { - updater.CancelStreamedStage(xferID, info.Generation, reason) - return false, reason + s.finishTargetCall(call, false, "stage_no_reply") + return } - return true, "" - case <-time.After(targetCallTimeout): - updater.CancelStreamedStage(xferID, info.Generation, "stage_timeout") - return false, "stage_timeout" + okReply, reason := decodeStageReply(rep.Payload) + s.finishTargetCall(call, okReply, reason) + return + default: + } + if !now.Before(call.deadline) { + s.finishTargetCall(call, false, "stage_timeout") + } +} + +func (s *session) cancelTargetCall(reason string) { + call := s.pendingTargetCall + if call == nil { + return + } + if reason == "" { + reason = reasonLinkDown + } + if call.sub != nil { + s.conn.Unsubscribe(call.sub) + call.sub = nil } + s.pendingTargetCall = nil + updater.CancelStreamedStage(call.xferID, call.info.Generation, reason) + otadiag.Event("[fabric-xfer]", "target_call_cancel", call.xferID, otadiag.KV("reason", reason)) } func decodeStageReply(payload any) (bool, string) { diff --git a/services/fabric/transfer_test.go b/services/fabric/transfer_test.go index 53daa2d..3464b39 100644 --- a/services/fabric/transfer_test.go +++ b/services/fabric/transfer_test.go @@ -1675,14 +1675,13 @@ func TestTransferTargetStageTimeoutCancelsLeaseAndPreventsLateStagePersist(t *te caller := b.NewConnection("caller") prepareUpdaterForFabricTest(t, caller) - oldTimeout := targetCallTimeout - targetCallTimeout = 20 * time.Millisecond - defer func() { targetCallTimeout = oldTimeout }() + cfg := DefaultLinkConfig() + cfg.TargetCallTimeout = 20 * time.Millisecond cm5, mcu := pipePair() ctx, cancel := context.WithCancel(context.Background()) defer cancel() - go Run(ctx, mcu, b.NewConnection("fabric"), "mcu", "bigbox-cm5", DefaultLinkConfig()) + go Run(ctx, mcu, b.NewConnection("fabric"), "mcu", "bigbox-cm5", cfg) bringUp(t, cm5) id := "xfer-stage-timeout" diff --git a/services/hal/devices/serial_raw/builder.go b/services/hal/devices/serial_raw/builder.go index 15ecaca..8285ff8 100644 --- a/services/hal/devices/serial_raw/builder.go +++ b/services/hal/devices/serial_raw/builder.go @@ -211,39 +211,6 @@ func (d *Device) Control(_ core.CapAddr, verb string, payload any) (core.Enqueue "tx_size", strconvx.Itoa(txSize), ) - // --- Device-level hygiene: drain spurious RX before signalling link up --- - // Discard any pre-existing or immediately-arriving bytes on the UART RX path. - // Uses a short quiet window so this remains bounded and non-blocking. - { - const quiet = 5 * time.Millisecond // time with no bytes before we stop - const maxTotal = 15 * time.Millisecond // absolute cap as a safeguard - - tmp := make([]byte, 64) - tStart := time.Now() - tQuiet := time.Now().Add(quiet) - - for { - // Non-blocking attempt to pull any pending bytes. - if n := d.port.TryRead(tmp); n > 0 { - // Extend the quiet window after activity. - tQuiet = time.Now().Add(quiet) - } else { - // No bytes right now. If we have been quiet long enough, or we have - // reached the absolute bound, stop draining. - now := time.Now() - if now.After(tQuiet) || now.Sub(tStart) >= maxTotal { - break - } - // Wait for either a UART RX edge or a very short back-off, then re-check. - select { - case <-d.port.Readable(): - case <-time.After(time.Millisecond): - } - } - } - } - // --- end hygiene --- - rep := types.SerialSessionOpened{ SessionID: d.sess.id, RXHandle: uint32(d.sess.rxHandle), diff --git a/utilities/jsonw.go b/utilities/jsonw.go index 355a80d..830edbc 100644 --- a/utilities/jsonw.go +++ b/utilities/jsonw.go @@ -1,86 +1,86 @@ -package utilities - -import "devicecode-go/x/strconvx" - -// ----------------------------------------------------------------------------- -// Minimal streaming JSON writer for shmring (no buffers/allocs) -// ----------------------------------------------------------------------------- - -type JSONWriter struct { - Write func([]byte) int - first bool -} - -func (w *JSONWriter) Begin() { - w.first = true - if w.Write != nil { - w.Write([]byte("{")) - } -} -func (w *JSONWriter) End() { - if w.Write != nil { - w.Write([]byte("}\n")) - } -} -func (w *JSONWriter) Comma() { - if w.Write == nil { - return - } - if !w.first { - w.Write([]byte(",")) - } else { - w.first = false - } -} -func (w *JSONWriter) Key(k string) { - if w.Write == nil { - return - } - w.Write([]byte(`"`)) - w.Write([]byte(k)) - w.Write([]byte(`":`)) -} -func (w *JSONWriter) KvInt(k string, v int) { - w.Comma() - w.Key(k) - if w.Write != nil { - w.Write([]byte(strconvx.Itoa(v))) - } -} -func (w *JSONWriter) KvStr(k, s string) { - w.Comma() - w.Key(k) - if w.Write == nil { - return - } - w.Write([]byte(`"`)) - for i := 0; i < len(s); i++ { - c := s[i] - switch c { - case '\\', '"': - w.Write([]byte{'\\', c}) - case '\b': - w.Write([]byte{'\\', 'b'}) - case '\f': - w.Write([]byte{'\\', 'f'}) - case '\n': - w.Write([]byte{'\\', 'n'}) - case '\r': - w.Write([]byte{'\\', 'r'}) - case '\t': - w.Write([]byte{'\\', 't'}) - default: - if c < 0x20 { - var buf [6]byte - buf[0], buf[1], buf[2], buf[3] = '\\', 'u', '0', '0' - const hex = "0123456789abcdef" - buf[4] = hex[c>>4] - buf[5] = hex[c&0xF] - w.Write(buf[:]) - } else { - w.Write([]byte{c}) - } - } - } - w.Write([]byte(`"`)) -} \ No newline at end of file +package utilities + +import "devicecode-go/x/strconvx" + +// ----------------------------------------------------------------------------- +// Minimal streaming JSON writer for shmring (no buffers/allocs) +// ----------------------------------------------------------------------------- + +type JSONWriter struct { + Write func([]byte) int + first bool +} + +func (w *JSONWriter) Begin() { + w.first = true + if w.Write != nil { + w.Write([]byte("{")) + } +} +func (w *JSONWriter) End() { + if w.Write != nil { + w.Write([]byte("}\n")) + } +} +func (w *JSONWriter) Comma() { + if w.Write == nil { + return + } + if !w.first { + w.Write([]byte(",")) + } else { + w.first = false + } +} +func (w *JSONWriter) Key(k string) { + if w.Write == nil { + return + } + w.Write([]byte(`"`)) + w.Write([]byte(k)) + w.Write([]byte(`":`)) +} +func (w *JSONWriter) KvInt(k string, v int) { + w.Comma() + w.Key(k) + if w.Write != nil { + w.Write([]byte(strconvx.Itoa(v))) + } +} +func (w *JSONWriter) KvStr(k, s string) { + w.Comma() + w.Key(k) + if w.Write == nil { + return + } + w.Write([]byte(`"`)) + for i := 0; i < len(s); i++ { + c := s[i] + switch c { + case '\\', '"': + w.Write([]byte{'\\', c}) + case '\b': + w.Write([]byte{'\\', 'b'}) + case '\f': + w.Write([]byte{'\\', 'f'}) + case '\n': + w.Write([]byte{'\\', 'n'}) + case '\r': + w.Write([]byte{'\\', 'r'}) + case '\t': + w.Write([]byte{'\\', 't'}) + default: + if c < 0x20 { + var buf [6]byte + buf[0], buf[1], buf[2], buf[3] = '\\', 'u', '0', '0' + const hex = "0123456789abcdef" + buf[4] = hex[c>>4] + buf[5] = hex[c&0xF] + w.Write(buf[:]) + } else { + w.Write([]byte{c}) + } + } + } + w.Write([]byte(`"`)) +} diff --git a/utilities/logger.go b/utilities/logger.go index 2fbc29b..e2417b1 100644 --- a/utilities/logger.go +++ b/utilities/logger.go @@ -1,191 +1,191 @@ -package utilities - -import ( - "time" - - "devicecode-go/x/shmring" - "devicecode-go/x/strconvx" -) - -// ----------------------------------------------------------------------------- -// Logger (mirrors to USB console and optionally uart1). No heap churn. -// ----------------------------------------------------------------------------- - -type Logger struct { - target *shmring.Ring - t0 time.Time - LineStart bool - droppedUART1Bytes int // mirror dropped bytes -} - -var nl = [...]byte{'\n'} - -func (l *Logger) SetStart(t time.Time) { l.t0, l.LineStart = t, true } -func (l *Logger) SetUART1(r *shmring.Ring) { l.target = r } - -func (l *Logger) writeString(s string) { - l.writePrefixIfLineStart() - if s != "" { - print(s) - l.logWrite([]byte(s)) - } -} -func (l *Logger) writeBytes(b []byte) { - if len(b) == 0 { - return - } - l.writePrefixIfLineStart() - print(string(b)) - l.logWrite(b) -} -func (l *Logger) writePrefixIfLineStart() { - if !l.LineStart { - return - } - l.LineStart = false - if l.t0.IsZero() { - l.t0 = time.Now() - } - el := time.Since(l.t0) - secs := int(el / time.Second) - ms := int((el % time.Second) / time.Millisecond) // 0..999 - - // Console (no allocations) - print(strconvx.Itoa(secs)) - print(".") - if ms < 100 { - print("0") - } - if ms < 10 { - print("0") - } - print(strconvx.Itoa(ms)) - print(" ") - - // UART1: build once, single write - if l.target != nil { - var buf [20]byte - n := 0 - n += writeDec(buf[:], n, secs) - buf[n] = '.' - n++ - n += writeDecPad3(buf[:], n, ms) - buf[n] = ' ' - n++ - l.logWrite(buf[:n]) - } -} -func writeDecPad3(dst []byte, off int, v int) int { - if v < 0 { - v = 0 - } else if v > 999 { - v = 999 - } - dst[off+0] = byte('0' + (v/100)%10) - dst[off+1] = byte('0' + (v/10)%10) - dst[off+2] = byte('0' + v%10) - return 3 -} -func writeDec(dst []byte, off int, v int) int { - if v == 0 { - dst[off] = '0' - return 1 - } - var tmp [10]byte - j := 0 - for v > 0 { - tmp[j] = byte('0' + v%10) - v /= 10 - j++ - } - i := off - for k := j - 1; k >= 0; k-- { - dst[i] = tmp[k] - i++ - } - return i - off -} -func (l *Logger) writePart(v any) { - switch x := v.(type) { - case string: - l.writeString(x) - case []byte: - l.writeBytes(x) - case int: - l.writeString(strconvx.Itoa(x)) - case int32: - l.writeString(strconvx.Itoa(int(x))) - case int64: - l.writeString(strconvx.Itoa64(x)) - case uint: - l.writeString(strconvx.Itoa(int(x))) - case uint32: - l.writeString(strconvx.Itoa(int(x))) - case uint64: - l.writeString(strconvx.Itoa64(int64(x))) - case bool: - if x { - l.writeString("true") - } else { - l.writeString("false") - } - default: - l.writeString("?") - } -} -func (l *Logger) Print(parts ...any) { - for i := range parts { - l.writePart(parts[i]) - } -} -func (l *Logger) newline() { - print("\n") - l.logWrite(nl[:]) - l.LineStart = true -} -func (l *Logger) Println(parts ...any) { l.Print(parts...); l.newline() } - -func (l *Logger) Deci(label string, deci int) { - l.writePrefixIfLineStart() - if deci < 0 { - l.writeString(label) - l.writeString("-") - deci = -deci - } else { - l.writeString(label) - } - whole := deci / 10 - frac := deci % 10 - l.Println(strconvx.Itoa(whole), ".", strconvx.Itoa(frac)) -} -func (l *Logger) Hundredths(label string, hx100 int) { - l.writePrefixIfLineStart() - if hx100 < 0 { - hx100 = 0 - } - whole := hx100 / 100 - frac := hx100 % 100 - if frac < 10 { - l.Println(label, strconvx.Itoa(whole), ".0", strconvx.Itoa(frac)) - } else { - l.Println(label, strconvx.Itoa(whole), ".", strconvx.Itoa(frac)) - } -} - -// uart1 (logger mirror) — returns bytes written; tracks dropped bytes on partial writes. -func (l *Logger) logWrite(b []byte) int { - if l == nil || l.target == nil || len(b) == 0 { - return 0 - } - n := l.target.TryWriteFrom(b) - if n < len(b) { - l.droppedUART1Bytes += (len(b) - n) - // Avoid recursion; print to console directly. - if l.droppedUART1Bytes == (len(b)-n) || (l.droppedUART1Bytes%1024) == 0 { - print("[uart1] dropped bytes = ") - print(strconvx.Itoa(l.droppedUART1Bytes)) - print("\n") - } - } - return n -} \ No newline at end of file +package utilities + +import ( + "time" + + "devicecode-go/x/shmring" + "devicecode-go/x/strconvx" +) + +// ----------------------------------------------------------------------------- +// Logger (mirrors to USB console and optionally uart1). No heap churn. +// ----------------------------------------------------------------------------- + +type Logger struct { + target *shmring.Ring + t0 time.Time + LineStart bool + droppedUART1Bytes int // mirror dropped bytes +} + +var nl = [...]byte{'\n'} + +func (l *Logger) SetStart(t time.Time) { l.t0, l.LineStart = t, true } +func (l *Logger) SetUART1(r *shmring.Ring) { l.target = r } + +func (l *Logger) writeString(s string) { + l.writePrefixIfLineStart() + if s != "" { + print(s) + l.logWrite([]byte(s)) + } +} +func (l *Logger) writeBytes(b []byte) { + if len(b) == 0 { + return + } + l.writePrefixIfLineStart() + print(string(b)) + l.logWrite(b) +} +func (l *Logger) writePrefixIfLineStart() { + if !l.LineStart { + return + } + l.LineStart = false + if l.t0.IsZero() { + l.t0 = time.Now() + } + el := time.Since(l.t0) + secs := int(el / time.Second) + ms := int((el % time.Second) / time.Millisecond) // 0..999 + + // Console (no allocations) + print(strconvx.Itoa(secs)) + print(".") + if ms < 100 { + print("0") + } + if ms < 10 { + print("0") + } + print(strconvx.Itoa(ms)) + print(" ") + + // UART1: build once, single write + if l.target != nil { + var buf [20]byte + n := 0 + n += writeDec(buf[:], n, secs) + buf[n] = '.' + n++ + n += writeDecPad3(buf[:], n, ms) + buf[n] = ' ' + n++ + l.logWrite(buf[:n]) + } +} +func writeDecPad3(dst []byte, off int, v int) int { + if v < 0 { + v = 0 + } else if v > 999 { + v = 999 + } + dst[off+0] = byte('0' + (v/100)%10) + dst[off+1] = byte('0' + (v/10)%10) + dst[off+2] = byte('0' + v%10) + return 3 +} +func writeDec(dst []byte, off int, v int) int { + if v == 0 { + dst[off] = '0' + return 1 + } + var tmp [10]byte + j := 0 + for v > 0 { + tmp[j] = byte('0' + v%10) + v /= 10 + j++ + } + i := off + for k := j - 1; k >= 0; k-- { + dst[i] = tmp[k] + i++ + } + return i - off +} +func (l *Logger) writePart(v any) { + switch x := v.(type) { + case string: + l.writeString(x) + case []byte: + l.writeBytes(x) + case int: + l.writeString(strconvx.Itoa(x)) + case int32: + l.writeString(strconvx.Itoa(int(x))) + case int64: + l.writeString(strconvx.Itoa64(x)) + case uint: + l.writeString(strconvx.Itoa(int(x))) + case uint32: + l.writeString(strconvx.Itoa(int(x))) + case uint64: + l.writeString(strconvx.Itoa64(int64(x))) + case bool: + if x { + l.writeString("true") + } else { + l.writeString("false") + } + default: + l.writeString("?") + } +} +func (l *Logger) Print(parts ...any) { + for i := range parts { + l.writePart(parts[i]) + } +} +func (l *Logger) newline() { + print("\n") + l.logWrite(nl[:]) + l.LineStart = true +} +func (l *Logger) Println(parts ...any) { l.Print(parts...); l.newline() } + +func (l *Logger) Deci(label string, deci int) { + l.writePrefixIfLineStart() + if deci < 0 { + l.writeString(label) + l.writeString("-") + deci = -deci + } else { + l.writeString(label) + } + whole := deci / 10 + frac := deci % 10 + l.Println(strconvx.Itoa(whole), ".", strconvx.Itoa(frac)) +} +func (l *Logger) Hundredths(label string, hx100 int) { + l.writePrefixIfLineStart() + if hx100 < 0 { + hx100 = 0 + } + whole := hx100 / 100 + frac := hx100 % 100 + if frac < 10 { + l.Println(label, strconvx.Itoa(whole), ".0", strconvx.Itoa(frac)) + } else { + l.Println(label, strconvx.Itoa(whole), ".", strconvx.Itoa(frac)) + } +} + +// uart1 (logger mirror) — returns bytes written; tracks dropped bytes on partial writes. +func (l *Logger) logWrite(b []byte) int { + if l == nil || l.target == nil || len(b) == 0 { + return 0 + } + n := l.target.TryWriteFrom(b) + if n < len(b) { + l.droppedUART1Bytes += (len(b) - n) + // Avoid recursion; print to console directly. + if l.droppedUART1Bytes == (len(b)-n) || (l.droppedUART1Bytes%1024) == 0 { + print("[uart1] dropped bytes = ") + print(strconvx.Itoa(l.droppedUART1Bytes)) + print("\n") + } + } + return n +} From 6971237f9bebd74ae23b1eb12fab76b6dd1674ba Mon Sep 17 00:00:00 2001 From: Rich Thanki Date: Mon, 8 Jun 2026 04:57:06 +0000 Subject: [PATCH 02/17] Fabric no longer materialises the staged artefact as a whole-image buffer --- services/fabric/transfer.go | 15 ++-- services/fabric/transfer_sink_buffer.go | 95 ------------------------- services/fabric/transfer_sink_rp2350.go | 6 -- services/fabric/transfer_sink_stub.go | 61 ++++++++++++++-- services/fabric/transfer_test.go | 26 ++----- services/updater/prestage_host.go | 91 +++++++++++++++++++++-- services/updater/receiver.go | 84 ++++------------------ services/updater/stream_lease.go | 12 ---- services/updater/types.go | 9 ++- services/updater/updater_test.go | 59 ++++++++------- 10 files changed, 201 insertions(+), 257 deletions(-) delete mode 100644 services/fabric/transfer_sink_buffer.go diff --git a/services/fabric/transfer.go b/services/fabric/transfer.go index 2a71a0c..5dc4bcc 100644 --- a/services/fabric/transfer.go +++ b/services/fabric/transfer.go @@ -46,17 +46,14 @@ type transferInfo struct { // canonical wire fields). No sequence number is passed — the caller has // already validated offset against expected progress. // -// Bytes() returns the committed payload bytes for target invocation. -// Only valid after Commit() has succeeded. May return nil if the sink -// streamed the bytes elsewhere (e.g. the RP2350 sink writes directly to -// flash and doesn't keep a RAM copy); updater/main consumes that staged -// stream from the updater package. +// The sink owns transfer bytes. Fabric never asks it for a whole-image +// []byte; after Commit succeeds the updater/main stage RPC consumes the +// committed streamed stage by xfer_id/generation. type transferSink interface { WriteChunk(offset uint32, data []byte) error Commit() (transferInfo, error) Apply() error Abort(reason string) error - Bytes() []byte } type incomingTransfer struct { @@ -638,11 +635,10 @@ func (s *session) onTransferCommit(msg *protoXferCommit) { otadiag.Event("[fabric-xfer]", "abort_tx", id, otadiag.KV("reason", reason), otadiag.KV("ok", abortOK)) return } - sink := cur.sink meta := cur.meta s.clearTransfer() - if reason := s.startTransferTargetCall(meta, id, info, sink.Bytes()); reason != "" { + if reason := s.startTransferTargetCall(meta, id, info); reason != "" { abortOK := s.sendTransferAbort(id, reason) otadiag.Event("[fabric-xfer]", "abort_tx", id, otadiag.KV("reason", reason), otadiag.KV("ok", abortOK)) return @@ -653,7 +649,7 @@ func (s *session) onTransferCommit(msg *protoXferCommit) { // blocking the Fabric session reactor. The reply is observed by drainTargetCall // on the normal session tick path; until then the session continues to process // pings, exports, replies and link events. -func (s *session) startTransferTargetCall(meta transferMeta, xferID string, info transferInfo, artefact []byte) string { +func (s *session) startTransferTargetCall(meta transferMeta, xferID string, info transferInfo) string { if meta.Target != transferTargetUpdaterMain { return "unsupported_target" } @@ -669,7 +665,6 @@ func (s *session) startTransferTargetCall(meta transferMeta, xferID string, info DigestAlg: meta.DigestAlg, Digest: meta.Digest, Meta: meta.Meta, - Artefact: artefact, } msg := s.conn.NewMessage(updater.TopicStageRPC, payload, false) s.pendingTargetCall = &pendingTargetCall{ diff --git a/services/fabric/transfer_sink_buffer.go b/services/fabric/transfer_sink_buffer.go deleted file mode 100644 index c012c66..0000000 --- a/services/fabric/transfer_sink_buffer.go +++ /dev/null @@ -1,95 +0,0 @@ -package fabric - -import ( - "errors" - - "devicecode-go/services/updater" -) - -// bufferSink is the default in-memory transferSink: it buffers the -// verified-by-wire (xxHash32) artefact in RAM and exposes the bytes via -// Bytes() so onTransferCommit can hand them to the updater/main staging -// RPC. The updater is responsible for signed-image verification and staging. -// -// Size cap is deliberately conservative: the smoke tests target small -// artefacts and large firmware images need a streaming-into-flash sink. -// Hitting the cap aborts the transfer cleanly via WriteChunk -> -// ErrArtefactTooLarge. -const maxArtefactBytes = 64 * 1024 - -var ErrArtefactTooLarge = errors.New("artefact_too_large") - -type bufferSink struct { - meta transferMeta - generation uint64 - buf []byte - closed bool - committed bool -} - -func newBufferSink(meta transferMeta) (*bufferSink, error) { - generation, err := updater.BeginStreamedStage(meta.ID, meta.Size) - if err != nil { - return nil, err - } - return &bufferSink{ - meta: meta, - generation: generation, - buf: make([]byte, 0, sizeHint(meta.Size)), - }, nil -} - -func sizeHint(announced uint32) int { - if announced == 0 || announced > maxArtefactBytes { - return maxArtefactBytes - } - return int(announced) -} - -func (s *bufferSink) WriteChunk(off uint32, data []byte) error { - if s.closed { - return errors.New("sink_closed") - } - if int(off) != len(s.buf) { - return errors.New("unexpected_offset") - } - if len(s.buf)+len(data) > maxArtefactBytes { - return ErrArtefactTooLarge - } - s.buf = append(s.buf, data...) - return nil -} - -func (s *bufferSink) Commit() (transferInfo, error) { - if s.closed { - return transferInfo{}, errors.New("sink_closed") - } - if s.generation != 0 { - if err := updater.CommitBufferedStage(s.meta.ID, s.generation); err != nil { - return transferInfo{}, err - } - } - s.committed = true - return transferInfo{BytesWritten: uint32(len(s.buf)), Generation: s.generation}, nil -} - -// Apply is a no-op for the buffer sink — the staged-image apply -// (slot switch + reboot) belongs to the updater's commit RPC, not to -// fabric's transfer state machine. -func (s *bufferSink) Apply() error { return nil } - -func (s *bufferSink) Abort(reason string) error { - if s.generation != 0 { - updater.AbortStreamedStage(s.meta.ID, s.generation, reason) - } - s.buf = nil - s.closed = true - return nil -} - -func (s *bufferSink) Bytes() []byte { - if !s.committed { - return nil - } - return s.buf -} diff --git a/services/fabric/transfer_sink_rp2350.go b/services/fabric/transfer_sink_rp2350.go index 56ba8de..6d9f5fc 100644 --- a/services/fabric/transfer_sink_rp2350.go +++ b/services/fabric/transfer_sink_rp2350.go @@ -56,9 +56,3 @@ func (s *streamedStageSink) Abort(reason string) error { s.closed = true return nil } - -// Bytes returns nil because the TinyGo RP2350 default path verifies the signed -// container while streaming and writes only the authenticated payload into the -// inactive slot. fabric still calls updater/main staging; the updater consumes -// the verified staged descriptor instead of an in-RAM artefact. -func (s *streamedStageSink) Bytes() []byte { return nil } diff --git a/services/fabric/transfer_sink_stub.go b/services/fabric/transfer_sink_stub.go index 9554cff..d98cd19 100644 --- a/services/fabric/transfer_sink_stub.go +++ b/services/fabric/transfer_sink_stub.go @@ -1,11 +1,62 @@ //go:build !(tinygo && rp2350) -// Host build (tests, dev tooling): same buffer-sink behaviour as the -// default RP2350 build. Lets unit tests exercise updater/main staging -// without firmware stubs in the way. - package fabric +import ( + "errors" + + "devicecode-go/services/updater" +) + +// streamedStageSink is also the host/dev default. It does not retain the +// transfer as a whole-image []byte in Fabric. Host builds stream into the +// updater's host pre-stage implementation; RP2350 builds use the hardware +// implementation in transfer_sink_rp2350.go. +type streamedStageSink struct { + xferID string + generation uint64 + accepted uint32 + closed bool +} + func beginTransfer(meta transferMeta) (transferSink, error) { - return newBufferSink(meta) + generation, err := updater.BeginStreamedStage(meta.ID, meta.Size) + if err != nil { + return nil, err + } + return &streamedStageSink{xferID: meta.ID, generation: generation}, nil +} + +func (s *streamedStageSink) WriteChunk(off uint32, data []byte) error { + if s.closed { + return errors.New("sink_closed") + } + if s.accepted != off { + return errors.New("unexpected_offset") + } + if err := updater.WriteStreamedStage(s.xferID, s.generation, data); err != nil { + return err + } + s.accepted += uint32(len(data)) + return nil +} + +func (s *streamedStageSink) Commit() (transferInfo, error) { + if s.closed { + return transferInfo{}, errors.New("sink_closed") + } + written, err := updater.CommitStreamedStage(s.xferID, s.generation) + if err != nil { + return transferInfo{}, err + } + s.closed = true + return transferInfo{BytesWritten: written, Generation: s.generation}, nil +} + +func (s *streamedStageSink) Apply() error { return nil } + +func (s *streamedStageSink) Abort(reason string) error { + updater.AbortStreamedStage(s.xferID, s.generation, reason) + s.closed = true + return nil } diff --git a/services/fabric/transfer_test.go b/services/fabric/transfer_test.go index 3464b39..821ebfc 100644 --- a/services/fabric/transfer_test.go +++ b/services/fabric/transfer_test.go @@ -56,10 +56,6 @@ func (s *fakeTransferSink) Abort(reason string) error { return nil } -// Bytes returns nil because the test fake doesn't retain a RAM copy -// of the transferred bytes — it tracks per-chunk writes instead. -func (s *fakeTransferSink) Bytes() []byte { return nil } - type diagCapture struct { mu sync.Mutex lines []string @@ -1427,18 +1423,6 @@ func TestTransferCommitDigestMismatchAborts(t *testing.T) { } } -// bufferingSinkAdapter wraps the production bufferSink so transfer tests -// can assert the bytes passed to updater/main staging. -type bufferingSinkAdapter struct { - *bufferSink - abortReasons []string -} - -func (b *bufferingSinkAdapter) Abort(reason string) error { - b.abortReasons = append(b.abortReasons, reason) - return b.bufferSink.Abort(reason) -} - func TestTransferTargetInvokedAfterCommit(t *testing.T) { // With target=updater/main, fabric calls the local updater stage RPC // after xfer_commit and before xfer_done. The wire never names a @@ -1450,7 +1434,7 @@ func TestTransferTargetInvokedAfterCommit(t *testing.T) { gotPayload := installStageResponder(t, b, updater.StageReply{OK: true, Stage: "staged"}) - sink := &bufferingSinkAdapter{bufferSink: &bufferSink{meta: transferMeta{Size: 4}, buf: make([]byte, 0, 4)}} + sink := &fakeTransferSink{commitInfo: transferInfo{BytesWritten: 4, Generation: 7}} s := session{ linkID: defaultLinkID, nodeID: "mcu", @@ -1459,7 +1443,6 @@ func TestTransferTargetInvokedAfterCommit(t *testing.T) { tr: mcu, conn: b.NewConnection("fabric"), beginTransfer: func(meta transferMeta) (transferSink, error) { - sink.bufferSink.meta = meta return sink, nil }, } @@ -1488,8 +1471,8 @@ func TestTransferTargetInvokedAfterCommit(t *testing.T) { if p.Target != updater.TargetUpdaterMain || p.DigestAlg != updater.DigestAlgXXHash32 || p.Digest != xxhashStr(payload) { t.Fatalf("stage contract fields wrong: %+v", p) } - if string(p.Artefact) != string(payload) { - t.Fatalf("stage artefact = %v, want %q", p.Artefact, payload) + if p.Size != uint32(len(payload)) || p.Generation != 7 { + t.Fatalf("stage size/generation wrong: %+v", p) } case <-time.After(2 * time.Second): t.Fatal("timeout waiting for stage call") @@ -1620,7 +1603,7 @@ func TestTransferTargetRejectAbortsTransfer(t *testing.T) { _ = installStageResponder(t, b, updater.StageReply{OK: false, Err: "manifest_check_failed"}) - sink := &bufferingSinkAdapter{bufferSink: &bufferSink{meta: transferMeta{Size: 4}, buf: make([]byte, 0, 4)}} + sink := &fakeTransferSink{commitInfo: transferInfo{BytesWritten: 4, Generation: 7}} s := session{ linkID: defaultLinkID, nodeID: "mcu", @@ -1629,7 +1612,6 @@ func TestTransferTargetRejectAbortsTransfer(t *testing.T) { tr: mcu, conn: b.NewConnection("fabric"), beginTransfer: func(meta transferMeta) (transferSink, error) { - sink.bufferSink.meta = meta return sink, nil }, } diff --git a/services/updater/prestage_host.go b/services/updater/prestage_host.go index 20f88fc..9f80db6 100644 --- a/services/updater/prestage_host.go +++ b/services/updater/prestage_host.go @@ -2,7 +2,11 @@ package updater -import "errors" +import ( + "errors" + "io" + "os" +) type streamedStage struct { Version string @@ -12,27 +16,102 @@ type streamedStage struct { PayloadSHA256 string } +var hostStreamedStage struct { + file *os.File + path string + desc streamedStage + ready bool +} + func startStreamedStage(xferID string, generation uint64, size uint32) error { _, _, _ = xferID, generation, size + abortStreamedStage() + f, err := os.CreateTemp("", "dcgo-streamed-stage-*") + if err != nil { + return err + } + hostStreamedStage.file = f + hostStreamedStage.path = f.Name() return nil } func writeStreamedStage(xferID string, generation uint64, data []byte) error { - _, _, _ = xferID, generation, data - return errors.New("streamed_stage_not_supported") + _, _ = xferID, generation + if len(data) == 0 { + return errors.New("empty_chunk") + } + if hostStreamedStage.file == nil { + return errors.New("streamed_stage_not_started") + } + _, err := hostStreamedStage.file.Write(data) + return err } func commitStreamedStage(xferID string, generation uint64) (streamedStage, error) { _, _ = xferID, generation - return streamedStage{}, errors.New("streamed_stage_not_supported") + f := hostStreamedStage.file + if f == nil { + return streamedStage{}, errors.New("streamed_stage_not_started") + } + if _, err := f.Seek(0, io.SeekStart); err != nil { + abortStreamedStage() + return streamedStage{}, err + } + svc := currentService() + if svc == nil { + abortStreamedStage() + return streamedStage{}, errors.New("updater_not_running") + } + sink, err := newSlotSink(0) + if err != nil { + abortStreamedStage() + return streamedStage{}, err + } + manifest, err := svc.verifier.Verify(f, sink) + if err != nil { + abortStreamedStage() + return streamedStage{}, err + } + desc := streamedStage{ + Version: manifest.Version, + BuildID: manifest.BuildID, + ImageID: manifest.ImageID, + Length: manifest.PayloadLength, + PayloadSHA256: manifest.PayloadSHA256, + } + hostStreamedStage.desc = desc + hostStreamedStage.ready = true + _ = f.Close() + _ = os.Remove(hostStreamedStage.path) + hostStreamedStage.file = nil + hostStreamedStage.path = "" + return desc, nil } -func abortStreamedStage() {} +func abortStreamedStage() { + if hostStreamedStage.file != nil { + _ = hostStreamedStage.file.Close() + } + if hostStreamedStage.path != "" { + _ = os.Remove(hostStreamedStage.path) + } + hostStreamedStage.file = nil + hostStreamedStage.path = "" + hostStreamedStage.desc = streamedStage{} + hostStreamedStage.ready = false +} func consumeStreamedStageResult() (streamedStage, bool) { - return streamedStage{}, false + if !hostStreamedStage.ready { + return streamedStage{}, false + } + out := hostStreamedStage.desc + hostStreamedStage.desc = streamedStage{} + hostStreamedStage.ready = false + return out, true } func discardStreamedStageResult() { + abortStreamedStage() clearABUpdateDiagHook() } diff --git a/services/updater/receiver.go b/services/updater/receiver.go index 58efb25..dce757f 100644 --- a/services/updater/receiver.go +++ b/services/updater/receiver.go @@ -1,7 +1,6 @@ package updater import ( - "bytes" "errors" "devicecode-go/bus" @@ -40,66 +39,10 @@ func (s *Service) handleStage(msg *bus.Message) { return } - if len(payload.Artefact) == 0 { - staged, ok := consumeStreamedStageResult() - if !ok { - s.failStage(payload, "artefact_missing") - s.reply(msg, StageReply{OK: false, Err: "artefact_missing"}) - return - } - if err := s.checkStreamedStageLease(payload.XferID, payload.Generation, true); err != nil { - s.failLateStage(payload, err) - s.reply(msg, StageReply{OK: false, Err: err.Error()}) - return - } - desc := StagedDescriptor{ - Version: staged.Version, - BuildID: staged.BuildID, - ImageID: staged.ImageID, - Length: staged.Length, - Slot: 0, - PayloadSHA256: staged.PayloadSHA256, - } - if err := s.metadataWrite.WriteStagedDescriptor(desc); err != nil { - s.failStage(payload, "metadata_write_failed:"+err.Error()) - s.reply(msg, StageReply{OK: false, Err: "metadata_write_failed"}) - return - } - if err := s.checkStreamedStageLease(payload.XferID, payload.Generation, true); err != nil { - s.failLateStage(payload, err) - s.reply(msg, StageReply{OK: false, Err: err.Error()}) - return - } - if !s.releaseStreamedStageLease(payload.XferID, payload.Generation) { - err := errors.New("stage_cancelled") - s.failLateStage(payload, err) - s.reply(msg, StageReply{OK: false, Err: err.Error()}) - return - } - s.setStagedImage(desc.ImageID, desc.Version) - s.transitionTo(StateStaged, "", desc.Version) - s.reply(msg, StageReply{OK: true, Stage: "staged"}) - return - } - - sink, err := newSlotSink(uint32(len(payload.Artefact))) - if err != nil { - s.failStage(payload, "sink_init_failed:"+err.Error()) - s.reply(msg, StageReply{OK: false, Err: "sink_init_failed"}) - return - } - if err := s.checkStreamedStageLease(payload.XferID, payload.Generation, true); err != nil { - _ = sink.Abort() - s.failLateStage(payload, err) - s.reply(msg, StageReply{OK: false, Err: err.Error()}) - return - } - manifest, err := s.verifier.Verify(bytes.NewReader(payload.Artefact), sink) - if err != nil { - // Verifier rejected the artefact. Clear any prior descriptor so a - // following commit cannot apply stale firmware from an older stage. - s.failStage(payload, err.Error()) - s.reply(msg, StageReply{OK: false, Err: err.Error()}) + staged, ok := consumeStreamedStageResult() + if !ok { + s.failStage(payload, "artefact_missing") + s.reply(msg, StageReply{OK: false, Err: "artefact_missing"}) return } if err := s.checkStreamedStageLease(payload.XferID, payload.Generation, true); err != nil { @@ -108,12 +51,12 @@ func (s *Service) handleStage(msg *bus.Message) { return } desc := StagedDescriptor{ - Version: manifest.Version, - BuildID: manifest.BuildID, - ImageID: manifest.ImageID, - Length: manifest.PayloadLength, - Slot: 0, // slot-pick comes from abupdate when hardware apply is wired - PayloadSHA256: manifest.PayloadSHA256, + Version: staged.Version, + BuildID: staged.BuildID, + ImageID: staged.ImageID, + Length: staged.Length, + Slot: 0, + PayloadSHA256: staged.PayloadSHA256, } if err := s.metadataWrite.WriteStagedDescriptor(desc); err != nil { s.failStage(payload, "metadata_write_failed:"+err.Error()) @@ -125,17 +68,14 @@ func (s *Service) handleStage(msg *bus.Message) { s.reply(msg, StageReply{OK: false, Err: err.Error()}) return } - if !s.releaseStreamedStageLease(payload.XferID, payload.Generation) { err := errors.New("stage_cancelled") s.failLateStage(payload, err) s.reply(msg, StageReply{OK: false, Err: err.Error()}) return } - s.setStagedImage(desc.ImageID, manifest.Version) - s.transitionTo(StateStaged, "", manifest.Version) - // Do not republish the software fact here: PayloadSHA256 describes the - // running image, while this descriptor describes the staged image. + s.setStagedImage(desc.ImageID, desc.Version) + s.transitionTo(StateStaged, "", desc.Version) s.reply(msg, StageReply{OK: true, Stage: "staged"}) } diff --git a/services/updater/stream_lease.go b/services/updater/stream_lease.go index 905e80b..82dfef6 100644 --- a/services/updater/stream_lease.go +++ b/services/updater/stream_lease.go @@ -155,18 +155,6 @@ func CommitStreamedStage(xferID string, generation uint64) (uint32, error) { return staged.Length, nil } -func CommitBufferedStage(xferID string, generation uint64) error { - s := currentService() - if s == nil { - return errors.New("updater_not_running") - } - if err := s.markStreamedStageCommitted(xferID, generation); err != nil { - return err - } - clearABUpdateDiagHook() - return nil -} - func AbortStreamedStage(xferID string, generation uint64, reason string) { abortStreamedStage() clearABUpdateDiagHook() diff --git a/services/updater/types.go b/services/updater/types.go index 095d8ea..44e9dc6 100644 --- a/services/updater/types.go +++ b/services/updater/types.go @@ -132,10 +132,10 @@ type StagedDescriptor struct { PayloadSHA256 string `json:"payload_sha256"` } -// StagePayload is the local updater/main staging RPC invoked by fabric -// after xfer_commit has verified size and transfer digest. It replaces -// the older meta.receiver/raw-member receive path; the CM5 supplies only -// target="updater/main" on the wire. +// StagePayload is the local updater/main staging RPC invoked by Fabric after +// xfer_commit has verified size and transfer digest and committed the streamed +// staging lease. The payload carries only metadata and the lease generation; it +// must never carry the whole artefact as a []byte on MCU builds. type StagePayload struct { LinkID string `json:"link_id"` XferID string `json:"xfer_id"` @@ -145,7 +145,6 @@ type StagePayload struct { DigestAlg string `json:"digest_alg"` Digest string `json:"digest"` Meta any `json:"meta,omitempty"` - Artefact []byte `json:"artefact,omitempty"` } type StageReply struct { diff --git a/services/updater/updater_test.go b/services/updater/updater_test.go index 931050c..9cd474b 100644 --- a/services/updater/updater_test.go +++ b/services/updater/updater_test.go @@ -298,7 +298,6 @@ func testStagePayload(id string, artefact []byte) StagePayload { Size: uint32(len(artefact)), DigestAlg: DigestAlgXXHash32, Digest: "deadbeef", - Artefact: artefact, } } @@ -325,12 +324,17 @@ func preparedStagePayload(t *testing.T, caller *bus.Connection, svc *Service, id case <-time.After(2 * time.Second): t.Fatal("timeout waiting for prepare reply") } - generation, err := svc.beginStreamedStageLease(id) + generation, err := BeginStreamedStage(id, uint32(len(artefact))) if err != nil { - t.Fatalf("begin stage lease: %v", err) + t.Fatalf("begin streamed stage: %v", err) } - if err := svc.markStreamedStageCommitted(id, generation); err != nil { - t.Fatalf("commit stage lease: %v", err) + if len(artefact) > 0 { + if err := WriteStreamedStage(id, generation, artefact); err != nil { + t.Fatalf("write streamed stage: %v", err) + } + } + if _, err := CommitStreamedStage(id, generation); err != nil { + t.Fatalf("commit streamed stage: %v", err) } payload := testStagePayload(id, artefact) payload.Generation = generation @@ -605,7 +609,7 @@ func TestPrepareOpensSingleReceivingStreamLeaseAndClearsStaleDescriptor(t *testi memMD := NewMemoryMetadata() _ = memMD.WriteStagedDescriptor(StagedDescriptor{Version: "old", ImageID: "old-image", PayloadSHA256: "old"}) - _, cancel := runService(t, b, Options{Conn: conn, Metadata: memMD, MetadataWrite: memMD}) + svc, cancel := runService(t, b, Options{Conn: conn, Metadata: memMD, MetadataWrite: memMD}) defer cancel() prepareUpdaterForLease(t, caller) @@ -629,14 +633,14 @@ func TestPrepareOpensSingleReceivingStreamLeaseAndClearsStaleDescriptor(t *testi if _, err := BeginStreamedStage("xfer-second", 4); err == nil || err.Error() != ErrBusy { t.Fatalf("second BeginStreamedStage err = %v, want busy", err) } - if err := CommitBufferedStage("wrong-xfer", gen); err == nil || err.Error() != "stage_generation_mismatch" { - t.Fatalf("wrong xfer CommitBufferedStage err = %v, want generation mismatch", err) + if err := svc.markStreamedStageCommitted("wrong-xfer", gen); err == nil || err.Error() != "stage_generation_mismatch" { + t.Fatalf("wrong xfer markStreamedStageCommitted err = %v, want generation mismatch", err) } - if err := CommitBufferedStage("xfer-lease", gen+1); err == nil || err.Error() != "stage_generation_mismatch" { - t.Fatalf("wrong generation CommitBufferedStage err = %v, want generation mismatch", err) + if err := svc.markStreamedStageCommitted("xfer-lease", gen+1); err == nil || err.Error() != "stage_generation_mismatch" { + t.Fatalf("wrong generation markStreamedStageCommitted err = %v, want generation mismatch", err) } - if err := CommitBufferedStage("xfer-lease", gen); err != nil { - t.Fatalf("matching CommitBufferedStage: %v", err) + if err := svc.markStreamedStageCommitted("xfer-lease", gen); err != nil { + t.Fatalf("matching markStreamedStageCommitted: %v", err) } } @@ -667,12 +671,12 @@ func TestPrepareAndCommitRejectWhileStreamLeaseActive(t *testing.T) { } } -func TestStreamedStageDiagHookClearsOnBufferedCommit(t *testing.T) { +func TestStreamedStageDiagHookClearsOnCommittedStage(t *testing.T) { b := newTestBus() conn := b.NewConnection("updater") caller := b.NewConnection("caller") - _, cancel := runService(t, b, Options{Conn: conn}) + svc, cancel := runService(t, b, Options{Conn: conn}) defer cancel() prepareUpdaterForLease(t, caller) gen, err := BeginStreamedStage("xfer-hook-commit", 4) @@ -682,11 +686,12 @@ func TestStreamedStageDiagHookClearsOnBufferedCommit(t *testing.T) { if !abupdateDiagHookActiveForTest() { t.Fatal("diagnostic hook inactive after BeginStreamedStage") } - if err := CommitBufferedStage("xfer-hook-commit", gen); err != nil { - t.Fatalf("CommitBufferedStage: %v", err) + if err := svc.markStreamedStageCommitted("xfer-hook-commit", gen); err != nil { + t.Fatalf("markStreamedStageCommitted: %v", err) } + clearABUpdateDiagHook() if abupdateDiagHookActiveForTest() { - t.Fatal("diagnostic hook still active after buffered commit") + t.Fatal("diagnostic hook still active after committed stage") } } @@ -765,7 +770,7 @@ func TestCancelStreamedStagePreventsLateStageSuccess(t *testing.T) { PayloadLength: 4, }} - _, cancel := runService(t, b, Options{ + svc, cancel := runService(t, b, Options{ Conn: conn, Verifier: verif, Metadata: memMD, @@ -777,8 +782,8 @@ func TestCancelStreamedStagePreventsLateStageSuccess(t *testing.T) { if err != nil { t.Fatalf("BeginStreamedStage: %v", err) } - if err := CommitBufferedStage("xfer-cancel", gen); err != nil { - t.Fatalf("CommitBufferedStage: %v", err) + if err := svc.markStreamedStageCommitted("xfer-cancel", gen); err != nil { + t.Fatalf("markStreamedStageCommitted: %v", err) } CancelStreamedStage("xfer-cancel", gen, "test_cancel") @@ -823,8 +828,11 @@ func TestReleasedStagedLeaseIgnoresLateCancel(t *testing.T) { if err != nil { t.Fatalf("BeginStreamedStage: %v", err) } - if err := CommitBufferedStage("xfer-released", gen); err != nil { - t.Fatalf("CommitBufferedStage: %v", err) + if err := WriteStreamedStage("xfer-released", gen, []byte("blob")); err != nil { + t.Fatalf("WriteStreamedStage: %v", err) + } + if _, err := CommitStreamedStage("xfer-released", gen); err != nil { + t.Fatalf("CommitStreamedStage: %v", err) } stage := testStagePayload("xfer-released", []byte("blob")) @@ -883,8 +891,11 @@ func TestStaleGenerationAndWrongXferCannotMutateStreamedStage(t *testing.T) { if _, err := CommitStreamedStage("xfer-current", gen+1); err == nil || err.Error() != "stage_generation_mismatch" { t.Fatalf("stale generation CommitStreamedStage err = %v, want generation mismatch", err) } - if err := CommitBufferedStage("xfer-current", gen); err != nil { - t.Fatalf("CommitBufferedStage: %v", err) + if err := WriteStreamedStage("xfer-current", gen, []byte("data")); err != nil { + t.Fatalf("WriteStreamedStage: %v", err) + } + if _, err := CommitStreamedStage("xfer-current", gen); err != nil { + t.Fatalf("CommitStreamedStage: %v", err) } for _, tc := range []struct { From 181eb379dfc7d64df813fc8ebe7adbe6edbbdf39 Mon Sep 17 00:00:00 2001 From: Rich Thanki Date: Mon, 8 Jun 2026 04:59:39 +0000 Subject: [PATCH 03/17] per-chunk updater writes are no longer performed inline in the Fabric session loop --- services/fabric/session.go | 2 + services/fabric/transfer.go | 280 +++++++++++++++++++++++-------- services/fabric/transfer_test.go | 61 ++++++- 3 files changed, 266 insertions(+), 77 deletions(-) diff --git a/services/fabric/session.go b/services/fabric/session.go index f3f8920..b96bc19 100644 --- a/services/fabric/session.go +++ b/services/fabric/session.go @@ -272,6 +272,8 @@ func (s *session) run(ctx context.Context) { s.drainExports() s.drainInbound(now) s.drainOutbound(now) + s.drainChunkWrite(now) + s.drainTransferCommit(now) s.checkTransferTimeout(now) s.drainTargetCall(now) s.tickPing(now) diff --git a/services/fabric/transfer.go b/services/fabric/transfer.go index 5dc4bcc..fab875d 100644 --- a/services/fabric/transfer.go +++ b/services/fabric/transfer.go @@ -65,12 +65,36 @@ type incomingTransfer struct { idleRetries uint8 corruptRetryOffset uint32 corruptRetriesAtOffset uint8 + pendingChunk *pendingChunkWrite + pendingCommit *pendingTransferCommit // deadline is the idle-chunk watchdog: bumped on every accepted chunk // and on initial xfer_begin. checkTransferTimeout fires if now > deadline. // Mirrors transfer_mgr.lua: `active.deadline = runtime.now() + phase_timeout`. + // While a chunk write is pending this also bounds the staging operation; + // the Fabric session loop stays live and the next xfer_need is not sent + // until the updater sink reports that the chunk has been accepted. deadline time.Time } +type pendingChunkWrite struct { + xferID string + offset uint32 + data []byte + started time.Time + resultCh chan error +} + +type pendingTransferCommit struct { + xferID string + started time.Time + resultCh chan transferCommitResult +} + +type transferCommitResult struct { + info transferInfo + err error +} + type completedTransfer struct { meta transferMeta } @@ -161,6 +185,13 @@ func (s *session) sendTransferAbort(id, reason string) bool { func (s *session) clearTransfer() *incomingTransfer { cur := s.incomingTransfer s.incomingTransfer = nil + if cur != nil && cur.pendingChunk != nil { + cur.pendingChunk.data = nil + cur.pendingChunk = nil + } + if cur != nil { + cur.pendingCommit = nil + } return cur } @@ -189,6 +220,20 @@ func (s *session) checkTransferTimeout(now time.Time) { if !now.After(cur.deadline) { return } + if cur.pendingChunk != nil { + id := cur.meta.ID + s.abortTransfer("chunk_write_timeout") + abortOK := s.sendTransferAbort(id, "chunk_write_timeout") + otadiag.Event("[fabric-xfer]", "abort_tx", id, otadiag.KV("reason", "chunk_write_timeout"), otadiag.KV("ok", abortOK)) + return + } + if cur.pendingCommit != nil { + id := cur.meta.ID + s.abortTransfer("transfer_commit_timeout") + abortOK := s.sendTransferAbort(id, "transfer_commit_timeout") + otadiag.Event("[fabric-xfer]", "abort_tx", id, otadiag.KV("reason", "transfer_commit_timeout"), otadiag.KV("ok", abortOK)) + return + } if cur.idleRetries < transferIdleRetryLimit { cur.idleRetries++ cur.deadline = now.Add(s.cfg.PhaseTimeout) @@ -418,6 +463,140 @@ func (s *session) onTransferBegin(msg *protoXferBegin) { } } +func (s *session) startPendingChunkWrite(cur *incomingTransfer, offset uint32, raw []byte) { + ch := make(chan error, 1) + sink := cur.sink + data := raw + started := time.Now() + cur.pendingChunk = &pendingChunkWrite{ + xferID: cur.meta.ID, + offset: offset, + data: data, + started: started, + resultCh: ch, + } + cur.deadline = started.Add(s.cfg.PhaseTimeout) + go func() { + ch <- sink.WriteChunk(offset, data) + }() +} + +func (s *session) drainChunkWrite(now time.Time) { + cur := s.incomingTransfer + if cur == nil || cur.pendingChunk == nil { + return + } + pending := cur.pendingChunk + select { + case err := <-pending.resultCh: + cur.pendingChunk = nil + if err != nil { + reason := err.Error() + otadiag.Event( + "[fabric-xfer]", "sink_write_error", pending.xferID, + otadiag.KV("reason", reason), + otadiag.KV("dur_ms", int(time.Since(pending.started)/time.Millisecond)), + ) + s.logKV("transfer write failed", "err", reason) + s.abortTransfer(reason) + abortOK := s.sendTransferAbort(pending.xferID, reason) + otadiag.Event("[fabric-xfer]", "abort_tx", pending.xferID, otadiag.KV("reason", reason), otadiag.KV("ok", abortOK)) + return + } + _, _ = cur.hasher.Write(pending.data) + cur.bytesWritten += uint32(len(pending.data)) + cur.chunksSeen++ + cur.idleRetries = 0 + cur.corruptRetryOffset = cur.bytesWritten + cur.corruptRetriesAtOffset = 0 + cur.deadline = now.Add(s.cfg.PhaseTimeout) + otadiag.Event( + "[fabric-xfer]", "sink_write_done", pending.xferID, + otadiag.KV("dur_ms", int(time.Since(pending.started)/time.Millisecond)), + otadiag.KV("next", u32s(cur.bytesWritten)), + ) + pending.data = nil + // Keep transfer memory bounded on TinyGo. The receiver allocates while + // unmarshalling JSON and decoding base64 chunks; without regular collection + // long updates can run out of heap before commit. + gcStart := time.Now() + otadiag.Event("[fabric-xfer]", "gc_start", pending.xferID, otadiag.KV("next", u32s(cur.bytesWritten))) + runtime.GC() + otadiag.Event( + "[fabric-xfer]", "gc_done", pending.xferID, + otadiag.KV("dur_ms", int(time.Since(gcStart)/time.Millisecond)), + otadiag.KV("next", cur.bytesWritten), + ) + needOK := s.sendTransferNeed(cur.meta.ID, cur.bytesWritten) + otadiag.Event( + "[fabric-xfer]", "need_tx", cur.meta.ID, + otadiag.KV("next", cur.bytesWritten), + otadiag.KV("ok", needOK), + otadiag.KV("accepted", true), + ) + if cur.bytesWritten != 0 && cur.bytesWritten%transferMemSampleStride == 0 { + var ms runtime.MemStats + runtime.ReadMemStats(&ms) + otadiag.Event( + "[fabric-xfer]", "transfer_mem_sample", cur.meta.ID, + otadiag.KV("next", cur.bytesWritten), + otadiag.KV("alloc", ms.Alloc), + otadiag.KV("heap", ms.HeapSys), + ) + } + default: + } +} + +func (s *session) startPendingTransferCommit(cur *incomingTransfer) { + ch := make(chan transferCommitResult, 1) + sink := cur.sink + started := time.Now() + cur.pendingCommit = &pendingTransferCommit{ + xferID: cur.meta.ID, + started: started, + resultCh: ch, + } + cur.deadline = started.Add(s.cfg.TargetCallTimeout) + go func() { + info, err := sink.Commit() + ch <- transferCommitResult{info: info, err: err} + }() +} + +func (s *session) drainTransferCommit(now time.Time) { + cur := s.incomingTransfer + if cur == nil || cur.pendingCommit == nil { + return + } + pending := cur.pendingCommit + select { + case res := <-pending.resultCh: + cur.pendingCommit = nil + if res.err != nil { + reason := res.err.Error() + s.logKV("transfer commit failed", "err", reason) + s.abortTransfer(reason) + abortOK := s.sendTransferAbort(pending.xferID, reason) + otadiag.Event("[fabric-xfer]", "abort_tx", pending.xferID, otadiag.KV("reason", reason), otadiag.KV("ok", abortOK)) + return + } + meta := cur.meta + s.clearTransfer() + otadiag.Event( + "[fabric-xfer]", "transfer_commit_done", pending.xferID, + otadiag.KV("dur_ms", int(time.Since(pending.started)/time.Millisecond)), + ) + if reason := s.startTransferTargetCall(meta, pending.xferID, res.info); reason != "" { + updater.CancelStreamedStage(pending.xferID, res.info.Generation, reason) + abortOK := s.sendTransferAbort(pending.xferID, reason) + otadiag.Event("[fabric-xfer]", "abort_tx", pending.xferID, otadiag.KV("reason", reason), otadiag.KV("ok", abortOK)) + } + default: + _ = now + } +} + func (s *session) onTransferChunk(msg *protoXferChunk) { cur := s.incomingTransfer if cur == nil || cur.meta.ID != msg.XferID { @@ -425,6 +604,25 @@ func (s *session) onTransferChunk(msg *protoXferChunk) { return } id := cur.meta.ID + if cur.pendingChunk != nil { + s.markRx() + otadiag.Event( + "[fabric-xfer]", "chunk_while_write_pending", id, + otadiag.KV("offset", u32s(msg.Offset)), + otadiag.KV("pending_offset", u32s(cur.pendingChunk.offset)), + otadiag.KV("expected", u32s(cur.bytesWritten)), + ) + return + } + if cur.pendingCommit != nil { + s.markRx() + otadiag.Event( + "[fabric-xfer]", "chunk_while_commit_pending", id, + otadiag.KV("offset", u32s(msg.Offset)), + otadiag.KV("expected", u32s(cur.bytesWritten)), + ) + return + } otadiag.Event( "[fabric-xfer]", "chunk_rx", id, otadiag.KV("offset", u32s(msg.Offset)), @@ -535,60 +733,12 @@ func (s *session) onTransferChunk(msg *protoXferChunk) { otadiag.KV("offset", u32s(msg.Offset)), otadiag.KV("raw_len", strconvx.Itoa(len(raw))), ) - if err := cur.sink.WriteChunk(msg.Offset, raw); err != nil { - reason := err.Error() - otadiag.Event( - "[fabric-xfer]", "sink_write_error", id, - otadiag.KV("reason", reason), - otadiag.KV("dur_ms", int(time.Since(writeStart)/time.Millisecond)), - ) - s.logKV("transfer write failed", "err", reason) - s.abortTransfer(reason) - abortOK := s.sendTransferAbort(id, reason) - otadiag.Event("[fabric-xfer]", "abort_tx", id, otadiag.KV("reason", reason), otadiag.KV("ok", abortOK)) - return - } - _, _ = cur.hasher.Write(raw) - cur.bytesWritten += uint32(len(raw)) - cur.chunksSeen++ - cur.idleRetries = 0 - cur.corruptRetryOffset = cur.bytesWritten - cur.corruptRetriesAtOffset = 0 - cur.deadline = time.Now().Add(s.cfg.PhaseTimeout) + s.startPendingChunkWrite(cur, msg.Offset, raw) otadiag.Event( - "[fabric-xfer]", "sink_write_done", id, + "[fabric-xfer]", "sink_write_pending", id, otadiag.KV("dur_ms", int(time.Since(writeStart)/time.Millisecond)), - otadiag.KV("next", u32s(cur.bytesWritten)), - ) - raw = nil - // Keep transfer memory bounded on TinyGo. The receiver allocates while - // unmarshalling JSON and decoding base64 chunks; without regular collection - // long updates can run out of heap before commit. - gcStart := time.Now() - otadiag.Event("[fabric-xfer]", "gc_start", id, otadiag.KV("next", u32s(cur.bytesWritten))) - runtime.GC() - otadiag.Event( - "[fabric-xfer]", "gc_done", id, - otadiag.KV("dur_ms", int(time.Since(gcStart)/time.Millisecond)), - otadiag.KV("next", u32s(cur.bytesWritten)), - ) - needOK := s.sendTransferNeed(cur.meta.ID, cur.bytesWritten) - otadiag.Event( - "[fabric-xfer]", "need_tx", cur.meta.ID, - otadiag.KV("next", cur.bytesWritten), - otadiag.KV("ok", needOK), - otadiag.KV("accepted", true), + otadiag.KV("offset", u32s(msg.Offset)), ) - if cur.bytesWritten != 0 && cur.bytesWritten%transferMemSampleStride == 0 { - var ms runtime.MemStats - runtime.ReadMemStats(&ms) - otadiag.Event( - "[fabric-xfer]", "transfer_mem_sample", cur.meta.ID, - otadiag.KV("next", cur.bytesWritten), - otadiag.KV("alloc", ms.Alloc), - otadiag.KV("heap", ms.HeapSys), - ) - } } func (s *session) onTransferCommit(msg *protoXferCommit) { @@ -598,6 +748,15 @@ func (s *session) onTransferCommit(msg *protoXferCommit) { return } id := cur.meta.ID + if cur.pendingChunk != nil { + s.markRx() + otadiag.Event( + "[fabric-xfer]", "commit_while_write_pending", id, + otadiag.KV("expected", u32s(cur.bytesWritten)), + otadiag.KV("pending_offset", u32s(cur.pendingChunk.offset)), + ) + return + } if msg.Size != cur.meta.Size || cur.bytesWritten != cur.meta.Size { reason := "short_transfer" s.abortTransfer(reason) @@ -626,23 +785,8 @@ func (s *session) onTransferCommit(msg *protoXferCommit) { return } s.markRx() - info, err := cur.sink.Commit() - if err != nil { - s.logKV("transfer commit failed", "err", err.Error()) - reason := err.Error() - s.abortTransfer(reason) - abortOK := s.sendTransferAbort(id, reason) - otadiag.Event("[fabric-xfer]", "abort_tx", id, otadiag.KV("reason", reason), otadiag.KV("ok", abortOK)) - return - } - meta := cur.meta - s.clearTransfer() - - if reason := s.startTransferTargetCall(meta, id, info); reason != "" { - abortOK := s.sendTransferAbort(id, reason) - otadiag.Event("[fabric-xfer]", "abort_tx", id, otadiag.KV("reason", reason), otadiag.KV("ok", abortOK)) - return - } + otadiag.Event("[fabric-xfer]", "transfer_commit_start", id) + s.startPendingTransferCommit(cur) } // startTransferTargetCall invokes the local updater/main staging RPC without diff --git a/services/fabric/transfer_test.go b/services/fabric/transfer_test.go index 821ebfc..958428e 100644 --- a/services/fabric/transfer_test.go +++ b/services/fabric/transfer_test.go @@ -18,18 +18,27 @@ import ( ) type fakeTransferSink struct { - offs []uint32 - writes [][]byte - writeErr error - commitErr error - applyErr error - commitInfo transferInfo - committed bool - applied bool - abortReasons []string + offs []uint32 + writes [][]byte + writeErr error + writeEntered chan struct{} + writeEnterOnce sync.Once + writeRelease chan struct{} + commitErr error + applyErr error + commitInfo transferInfo + committed bool + applied bool + abortReasons []string } func (s *fakeTransferSink) WriteChunk(off uint32, data []byte) error { + if s.writeEntered != nil { + s.writeEnterOnce.Do(func() { close(s.writeEntered) }) + } + if s.writeRelease != nil { + <-s.writeRelease + } if s.writeErr != nil { return s.writeErr } @@ -730,6 +739,40 @@ func TestTransferAcceptedChunkEmitsProcessingDiagnostics(t *testing.T) { assertDiagNotContains(t, diag.snapshot(), "[fabric-xfer]", "xfer_id xfer-chunk-diag", "ev transfer_mem_sample") } +func TestTransferNeedIsSentOnlyAfterPendingChunkWriteCompletes(t *testing.T) { + diag := captureOTADiag(t) + b := newBus() + cm5, mcu := pipePair() + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + sink := &fakeTransferSink{ + writeEntered: make(chan struct{}), + writeRelease: make(chan struct{}), + } + go runSessionWithSink(ctx, mcu, b.NewConnection("fabric"), sink) + bringUp(t, cm5) + + payload := []byte("abcd") + sendMsg(t, cm5, xferBegin("xfer-pending-chunk", payload, nil)) + readTransferReady(t, cm5, "xfer-pending-chunk", 0) + + sendMsg(t, cm5, xferChunk("xfer-pending-chunk", 0, payload)) + select { + case <-sink.writeEntered: + case <-time.After(time.Second): + t.Fatal("sink write did not start") + } + time.Sleep(100 * time.Millisecond) + assertDiagNotContains(t, diag.snapshot(), "[fabric-xfer]", "xfer_id xfer-pending-chunk", "ev need_tx", "accepted true") + + close(sink.writeRelease) + need := readMsg[protoXferNeed](t, cm5) + if need.Next != uint32(len(payload)) { + t.Fatalf("xfer_need.next = %d, want %d", need.Next, len(payload)) + } +} + func TestTransferAcceptedChunkEmitsSparseMemorySample(t *testing.T) { diag := captureOTADiag(t) b := newBus() From 94c75da6bb1249017c8a89a3a23ec633368fbd40 Mon Sep 17 00:00:00 2001 From: Rich Thanki Date: Mon, 8 Jun 2026 05:04:10 +0000 Subject: [PATCH 04/17] pending chunk writes, commit work, and target-stage replies wake the Fabric reactor directly --- services/fabric/fabric_test.go | 11 +- services/fabric/session.go | 48 ++++++- services/fabric/transfer.go | 229 ++++++++++++++++++------------- services/fabric/transfer_test.go | 10 +- 4 files changed, 183 insertions(+), 115 deletions(-) diff --git a/services/fabric/fabric_test.go b/services/fabric/fabric_test.go index 5c50472..dd3a1c7 100644 --- a/services/fabric/fabric_test.go +++ b/services/fabric/fabric_test.go @@ -1251,7 +1251,7 @@ func TestDrainExportsWaitsForStartupHoldoff(t *testing.T) { } } -func TestDrainExportsPausesDuringIncomingTransfer(t *testing.T) { +func TestDrainExportsContinuesDuringIncomingTransfer(t *testing.T) { b := newBus() fabricConn := b.NewConnection("fabric") pubConn := b.NewConnection("publisher") @@ -1274,15 +1274,8 @@ func TestDrainExportsPausesDuringIncomingTransfer(t *testing.T) { )) s.drainExports() - if len(tr.writes) != 0 { - t.Fatalf("writes during transfer = %d, want 0", len(tr.writes)) - } - - s.incomingTransfer = nil - s.drainExports() - if len(tr.writes) != 1 { - t.Fatalf("writes after transfer = %d, want 1", len(tr.writes)) + t.Fatalf("writes during transfer = %d, want 1", len(tr.writes)) } } diff --git a/services/fabric/session.go b/services/fabric/session.go index b96bc19..2d53500 100644 --- a/services/fabric/session.go +++ b/services/fabric/session.go @@ -239,16 +239,24 @@ func (s *session) run(ctx context.Context) { waitTick := time.NewTicker(waitLogEvery) defer waitTick.Stop() - // Poll subscription channels periodically. Needed because select - // blocks until a line/timer fires; without this, exported bus - // messages and async call replies would sit in subscription channels. + // Poll export/RPC subscriptions periodically. Pending transfer/updater + // operations below are not polled here: their completion channels are + // selected directly so flash/stage progress wakes the reactor immediately. exportTick := time.NewTicker(exportTickInterval) defer exportTick.Stop() + pendingDeadline := time.NewTimer(time.Hour) + if !pendingDeadline.Stop() { + <-pendingDeadline.C + } + defer pendingDeadline.Stop() + s.publishLinkState("", "") s.log("run start") for { + pendingAt, pendingOK := s.nextPendingDeadline(time.Now()) + pendingDeadlineCh := resetOptionalTimer(pendingDeadline, pendingAt, pendingOK) select { case <-ctx.Done(): return @@ -267,15 +275,23 @@ func (s *session) run(ctx context.Context) { resetTimer(stale, s.cfg.LivenessTimeout) } + case err := <-s.pendingChunkReady(): + s.finishChunkWrite(time.Now(), err) + + case res := <-s.pendingCommitReady(): + s.finishTransferCommit(time.Now(), res) + + case rep, ok := <-s.pendingTargetReady(): + s.finishTargetReply(rep, ok) + + case <-pendingDeadlineCh: + s.handlePendingDeadline(time.Now()) + case <-exportTick.C: now := time.Now() s.drainExports() s.drainInbound(now) s.drainOutbound(now) - s.drainChunkWrite(now) - s.drainTransferCommit(now) - s.checkTransferTimeout(now) - s.drainTargetCall(now) s.tickPing(now) s.tickReady(now) @@ -300,6 +316,24 @@ func shouldLogFabricRead(msgType string, _, _ time.Duration) bool { return false } +func resetOptionalTimer(t *time.Timer, deadline time.Time, ok bool) <-chan time.Time { + if !t.Stop() { + select { + case <-t.C: + default: + } + } + if !ok || deadline.IsZero() { + return nil + } + d := time.Until(deadline) + if d < 0 { + d = 0 + } + t.Reset(d) + return t.C +} + func resetTimer(t *time.Timer, d time.Duration) { if !t.Stop() { select { diff --git a/services/fabric/transfer.go b/services/fabric/transfer.go index fab875d..35b918d 100644 --- a/services/fabric/transfer.go +++ b/services/fabric/transfer.go @@ -107,6 +107,63 @@ type pendingTargetCall struct { deadline time.Time } +func (s *session) pendingChunkReady() <-chan error { + cur := s.incomingTransfer + if cur == nil || cur.pendingChunk == nil { + return nil + } + return cur.pendingChunk.resultCh +} + +func (s *session) pendingCommitReady() <-chan transferCommitResult { + cur := s.incomingTransfer + if cur == nil || cur.pendingCommit == nil { + return nil + } + return cur.pendingCommit.resultCh +} + +func (s *session) pendingTargetReady() <-chan *bus.Message { + call := s.pendingTargetCall + if call == nil || call.sub == nil { + return nil + } + return call.sub.Channel() +} + +func earlierDeadline(a time.Time, aOK bool, b time.Time, bOK bool) (time.Time, bool) { + if !aOK { + return b, bOK + } + if !bOK { + return a, true + } + if b.Before(a) { + return b, true + } + return a, true +} + +func (s *session) nextPendingDeadline(now time.Time) (time.Time, bool) { + var out time.Time + var ok bool + if cur := s.incomingTransfer; cur != nil && !cur.deadline.IsZero() { + out, ok = earlierDeadline(out, ok, cur.deadline, true) + } + if call := s.pendingTargetCall; call != nil && !call.deadline.IsZero() { + out, ok = earlierDeadline(out, ok, call.deadline, true) + } + return out, ok +} + +func (s *session) handlePendingDeadline(now time.Time) { + s.checkTransferTimeout(now) + call := s.pendingTargetCall + if call != nil && !now.Before(call.deadline) { + s.finishTargetCall(call, false, "stage_timeout") + } +} + func sameTransferTuple(a, b transferMeta) bool { return a.ID == b.ID && a.Target == b.Target && @@ -481,70 +538,66 @@ func (s *session) startPendingChunkWrite(cur *incomingTransfer, offset uint32, r }() } -func (s *session) drainChunkWrite(now time.Time) { +func (s *session) finishChunkWrite(now time.Time, err error) { cur := s.incomingTransfer if cur == nil || cur.pendingChunk == nil { return } pending := cur.pendingChunk - select { - case err := <-pending.resultCh: - cur.pendingChunk = nil - if err != nil { - reason := err.Error() - otadiag.Event( - "[fabric-xfer]", "sink_write_error", pending.xferID, - otadiag.KV("reason", reason), - otadiag.KV("dur_ms", int(time.Since(pending.started)/time.Millisecond)), - ) - s.logKV("transfer write failed", "err", reason) - s.abortTransfer(reason) - abortOK := s.sendTransferAbort(pending.xferID, reason) - otadiag.Event("[fabric-xfer]", "abort_tx", pending.xferID, otadiag.KV("reason", reason), otadiag.KV("ok", abortOK)) - return - } - _, _ = cur.hasher.Write(pending.data) - cur.bytesWritten += uint32(len(pending.data)) - cur.chunksSeen++ - cur.idleRetries = 0 - cur.corruptRetryOffset = cur.bytesWritten - cur.corruptRetriesAtOffset = 0 - cur.deadline = now.Add(s.cfg.PhaseTimeout) + cur.pendingChunk = nil + if err != nil { + reason := err.Error() otadiag.Event( - "[fabric-xfer]", "sink_write_done", pending.xferID, + "[fabric-xfer]", "sink_write_error", pending.xferID, + otadiag.KV("reason", reason), otadiag.KV("dur_ms", int(time.Since(pending.started)/time.Millisecond)), - otadiag.KV("next", u32s(cur.bytesWritten)), ) - pending.data = nil - // Keep transfer memory bounded on TinyGo. The receiver allocates while - // unmarshalling JSON and decoding base64 chunks; without regular collection - // long updates can run out of heap before commit. - gcStart := time.Now() - otadiag.Event("[fabric-xfer]", "gc_start", pending.xferID, otadiag.KV("next", u32s(cur.bytesWritten))) - runtime.GC() - otadiag.Event( - "[fabric-xfer]", "gc_done", pending.xferID, - otadiag.KV("dur_ms", int(time.Since(gcStart)/time.Millisecond)), - otadiag.KV("next", cur.bytesWritten), - ) - needOK := s.sendTransferNeed(cur.meta.ID, cur.bytesWritten) + s.logKV("transfer write failed", "err", reason) + s.abortTransfer(reason) + abortOK := s.sendTransferAbort(pending.xferID, reason) + otadiag.Event("[fabric-xfer]", "abort_tx", pending.xferID, otadiag.KV("reason", reason), otadiag.KV("ok", abortOK)) + return + } + _, _ = cur.hasher.Write(pending.data) + cur.bytesWritten += uint32(len(pending.data)) + cur.chunksSeen++ + cur.idleRetries = 0 + cur.corruptRetryOffset = cur.bytesWritten + cur.corruptRetriesAtOffset = 0 + cur.deadline = now.Add(s.cfg.PhaseTimeout) + otadiag.Event( + "[fabric-xfer]", "sink_write_done", pending.xferID, + otadiag.KV("dur_ms", int(time.Since(pending.started)/time.Millisecond)), + otadiag.KV("next", u32s(cur.bytesWritten)), + ) + pending.data = nil + // Keep transfer memory bounded on TinyGo. The receiver allocates while + // unmarshalling JSON and decoding base64 chunks; without regular collection + // long updates can run out of heap before commit. + gcStart := time.Now() + otadiag.Event("[fabric-xfer]", "gc_start", pending.xferID, otadiag.KV("next", u32s(cur.bytesWritten))) + runtime.GC() + otadiag.Event( + "[fabric-xfer]", "gc_done", pending.xferID, + otadiag.KV("dur_ms", int(time.Since(gcStart)/time.Millisecond)), + otadiag.KV("next", cur.bytesWritten), + ) + needOK := s.sendTransferNeed(cur.meta.ID, cur.bytesWritten) + otadiag.Event( + "[fabric-xfer]", "need_tx", cur.meta.ID, + otadiag.KV("next", cur.bytesWritten), + otadiag.KV("ok", needOK), + otadiag.KV("accepted", true), + ) + if cur.bytesWritten != 0 && cur.bytesWritten%transferMemSampleStride == 0 { + var ms runtime.MemStats + runtime.ReadMemStats(&ms) otadiag.Event( - "[fabric-xfer]", "need_tx", cur.meta.ID, + "[fabric-xfer]", "transfer_mem_sample", cur.meta.ID, otadiag.KV("next", cur.bytesWritten), - otadiag.KV("ok", needOK), - otadiag.KV("accepted", true), + otadiag.KV("alloc", ms.Alloc), + otadiag.KV("heap", ms.HeapSys), ) - if cur.bytesWritten != 0 && cur.bytesWritten%transferMemSampleStride == 0 { - var ms runtime.MemStats - runtime.ReadMemStats(&ms) - otadiag.Event( - "[fabric-xfer]", "transfer_mem_sample", cur.meta.ID, - otadiag.KV("next", cur.bytesWritten), - otadiag.KV("alloc", ms.Alloc), - otadiag.KV("heap", ms.HeapSys), - ) - } - default: } } @@ -564,37 +617,33 @@ func (s *session) startPendingTransferCommit(cur *incomingTransfer) { }() } -func (s *session) drainTransferCommit(now time.Time) { +func (s *session) finishTransferCommit(now time.Time, res transferCommitResult) { cur := s.incomingTransfer if cur == nil || cur.pendingCommit == nil { return } pending := cur.pendingCommit - select { - case res := <-pending.resultCh: - cur.pendingCommit = nil - if res.err != nil { - reason := res.err.Error() - s.logKV("transfer commit failed", "err", reason) - s.abortTransfer(reason) - abortOK := s.sendTransferAbort(pending.xferID, reason) - otadiag.Event("[fabric-xfer]", "abort_tx", pending.xferID, otadiag.KV("reason", reason), otadiag.KV("ok", abortOK)) - return - } - meta := cur.meta - s.clearTransfer() - otadiag.Event( - "[fabric-xfer]", "transfer_commit_done", pending.xferID, - otadiag.KV("dur_ms", int(time.Since(pending.started)/time.Millisecond)), - ) - if reason := s.startTransferTargetCall(meta, pending.xferID, res.info); reason != "" { - updater.CancelStreamedStage(pending.xferID, res.info.Generation, reason) - abortOK := s.sendTransferAbort(pending.xferID, reason) - otadiag.Event("[fabric-xfer]", "abort_tx", pending.xferID, otadiag.KV("reason", reason), otadiag.KV("ok", abortOK)) - } - default: - _ = now + cur.pendingCommit = nil + if res.err != nil { + reason := res.err.Error() + s.logKV("transfer commit failed", "err", reason) + s.abortTransfer(reason) + abortOK := s.sendTransferAbort(pending.xferID, reason) + otadiag.Event("[fabric-xfer]", "abort_tx", pending.xferID, otadiag.KV("reason", reason), otadiag.KV("ok", abortOK)) + return + } + meta := cur.meta + s.clearTransfer() + otadiag.Event( + "[fabric-xfer]", "transfer_commit_done", pending.xferID, + otadiag.KV("dur_ms", int(time.Since(pending.started)/time.Millisecond)), + ) + if reason := s.startTransferTargetCall(meta, pending.xferID, res.info); reason != "" { + updater.CancelStreamedStage(pending.xferID, res.info.Generation, reason) + abortOK := s.sendTransferAbort(pending.xferID, reason) + otadiag.Event("[fabric-xfer]", "abort_tx", pending.xferID, otadiag.KV("reason", reason), otadiag.KV("ok", abortOK)) } + _ = now } func (s *session) onTransferChunk(msg *protoXferChunk) { @@ -790,9 +839,9 @@ func (s *session) onTransferCommit(msg *protoXferCommit) { } // startTransferTargetCall invokes the local updater/main staging RPC without -// blocking the Fabric session reactor. The reply is observed by drainTargetCall -// on the normal session tick path; until then the session continues to process -// pings, exports, replies and link events. +// blocking the Fabric session reactor. The reply channel is selected directly +// by session.run, so completion wakes the reactor without waiting for a +// periodic tick. func (s *session) startTransferTargetCall(meta transferMeta, xferID string, info transferInfo) string { if meta.Target != transferTargetUpdaterMain { return "unsupported_target" @@ -848,25 +897,17 @@ func (s *session) finishTargetCall(call *pendingTargetCall, ok bool, reason stri otadiag.Event("[fabric-xfer]", "abort_tx", call.xferID, otadiag.KV("reason", reason), otadiag.KV("ok", abortOK)) } -func (s *session) drainTargetCall(now time.Time) { +func (s *session) finishTargetReply(rep *bus.Message, ok bool) { call := s.pendingTargetCall if call == nil { return } - select { - case rep, ok := <-call.sub.Channel(): - if !ok || rep == nil { - s.finishTargetCall(call, false, "stage_no_reply") - return - } - okReply, reason := decodeStageReply(rep.Payload) - s.finishTargetCall(call, okReply, reason) + if !ok || rep == nil { + s.finishTargetCall(call, false, "stage_no_reply") return - default: - } - if !now.Before(call.deadline) { - s.finishTargetCall(call, false, "stage_timeout") } + okReply, reason := decodeStageReply(rep.Payload) + s.finishTargetCall(call, okReply, reason) } func (s *session) cancelTargetCall(reason string) { diff --git a/services/fabric/transfer_test.go b/services/fabric/transfer_test.go index 958428e..85d1102 100644 --- a/services/fabric/transfer_test.go +++ b/services/fabric/transfer_test.go @@ -1677,7 +1677,7 @@ func TestTransferTargetRejectAbortsTransfer(t *testing.T) { } } -func TestTransferTargetStageTimeoutCancelsLeaseAndPreventsLateStagePersist(t *testing.T) { +func TestTransferCommitTimeoutCancelsLeaseAndPreventsLateStagePersist(t *testing.T) { b := newBus() memMD := updater.NewMemoryMetadata() verif := &blockingVerifier{ @@ -1719,17 +1719,17 @@ func TestTransferTargetStageTimeoutCancelsLeaseAndPreventsLateStagePersist(t *te select { case <-verif.entered: case <-time.After(2 * time.Second): - t.Fatal("verifier did not start before stage timeout") + t.Fatal("verifier did not start before commit timeout") } - readTransferAbort(t, cm5, id, "stage_timeout") + readTransferAbort(t, cm5, id, "transfer_commit_timeout") if _, ok := memMD.StagedDescriptor(); ok { - t.Fatal("stage timeout persisted descriptor before verifier returned") + t.Fatal("commit timeout persisted descriptor before verifier returned") } close(verif.release) time.Sleep(50 * time.Millisecond) if _, ok := memMD.StagedDescriptor(); ok { - t.Fatal("late verifier completion after stage timeout persisted descriptor") + t.Fatal("late verifier completion after commit timeout persisted descriptor") } } From 656dfbc5759f3865cc327ccb5e8ed40d2168a19f Mon Sep 17 00:00:00 2001 From: Rich Thanki Date: Mon, 8 Jun 2026 05:16:08 +0000 Subject: [PATCH 05/17] fully evented fabric --- services/fabric/session.go | 432 +++++++++++++++++++++++++----------- services/fabric/transfer.go | 21 ++ 2 files changed, 326 insertions(+), 127 deletions(-) diff --git a/services/fabric/session.go b/services/fabric/session.go index 2d53500..f91e8ee 100644 --- a/services/fabric/session.go +++ b/services/fabric/session.go @@ -49,9 +49,8 @@ const ( // exportMaxPerTick caps the total export messages sent per drain // cycle across all subscriptions, keeping UART throughput within // the 115200-baud link capacity. - exportMaxPerTick = 1 - exportTickInterval = 50 * time.Millisecond - errPayloadMarshal = "payload_marshal_failed" + exportMaxPerTick = 1 + errPayloadMarshal = "payload_marshal_failed" ) // ---- link reasons and error strings ---- @@ -91,6 +90,24 @@ type readResult struct { err error } +type sessionEventKind uint8 + +const ( + sessionEventCriticalExport sessionEventKind = iota + 1 + sessionEventExport + sessionEventOutboundCall + sessionEventInboundReply +) + +type sessionEvent struct { + kind sessionEventKind + msg *bus.Message + sub *bus.Subscription + idx int + callID string + closed bool +} + type linkStatePayload struct { LinkID string `json:"link_id"` Status string `json:"status"` @@ -136,6 +153,8 @@ type session struct { criticalExportSubs []*bus.Subscription criticalExportReplayPending []bool + criticalExportPendingMsgs []*bus.Message + exportPendingMsgs []*bus.Message exportSubs []*bus.Subscription exportCallSubs []*bus.Subscription inboundCalls []*inboundCall @@ -151,6 +170,8 @@ type session struct { completedTransfers []completedTransfer pendingTargetCall *pendingTargetCall beginTransfer func(transferMeta) (transferSink, error) + events chan sessionEvent + ctx context.Context } func (s *session) log(msg string) { @@ -170,6 +191,8 @@ func (s *session) logKV(msg, key, value string) { // run is the main loop. Blocks until ctx is cancelled. func (s *session) run(ctx context.Context) { s.cfg.applyDefaults() + s.ctx = ctx + s.events = make(chan sessionEvent, 64) lines := make(chan readResult, lineQueueSize) go func() { @@ -239,11 +262,8 @@ func (s *session) run(ctx context.Context) { waitTick := time.NewTicker(waitLogEvery) defer waitTick.Stop() - // Poll export/RPC subscriptions periodically. Pending transfer/updater - // operations below are not polled here: their completion channels are - // selected directly so flash/stage progress wakes the reactor immediately. - exportTick := time.NewTicker(exportTickInterval) - defer exportTick.Stop() + // Bus subscriptions and pending transfer/updater operations wake the + // reactor directly. Timers below cover deadlines and periodic liveness only. pendingDeadline := time.NewTimer(time.Hour) if !pendingDeadline.Stop() { @@ -287,13 +307,8 @@ func (s *session) run(ctx context.Context) { case <-pendingDeadlineCh: s.handlePendingDeadline(time.Now()) - case <-exportTick.C: - now := time.Now() - s.drainExports() - s.drainInbound(now) - s.drainOutbound(now) - s.tickPing(now) - s.tickReady(now) + case ev := <-s.events: + s.handleSessionEvent(time.Now(), ev) case <-waitTick.C: s.logWaiting() @@ -501,6 +516,7 @@ func (s *session) tickReady(now time.Time) { } s.rpcReady = true s.publishLinkState("", "") + s.drainQueuedExports() } // ---- dispatch ---- @@ -1019,14 +1035,16 @@ func (s *session) onCall(msg *protoCall) { ) sub := s.conn.Request(busMsg) topicCopy := append([]string(nil), msg.Topic...) - s.inboundCalls = append(s.inboundCalls, &inboundCall{ + call := &inboundCall{ id: msg.ID, topic: topicCopy, localTopic: localTopic, payload: append(json.RawMessage(nil), msg.Payload...), sub: sub, deadline: time.Now().Add(timeout), - }) + } + s.inboundCalls = append(s.inboundCalls, call) + s.watchSubscription(sub, sessionEventInboundReply, -1, msg.ID) } func (s *session) onReply(msg *protoReply) { @@ -1100,19 +1118,73 @@ func marshalPayload(payload any) (json.RawMessage, error) { // Exports are drained inline in the main loop (no extra goroutines) // to avoid TinyGo cooperative scheduler mutex panics. +func (s *session) watchSubscription(sub *bus.Subscription, kind sessionEventKind, idx int, callID string) { + if sub == nil || s.ctx == nil || s.events == nil { + return + } + ctx := s.ctx + go func() { + for { + select { + case m, ok := <-sub.Channel(): + ev := sessionEvent{kind: kind, sub: sub, idx: idx, callID: callID, msg: m, closed: !ok} + select { + case s.events <- ev: + case <-ctx.Done(): + } + if !ok { + return + } + case <-ctx.Done(): + return + } + } + }() +} + +func (s *session) handleSessionEvent(now time.Time, ev sessionEvent) { + switch ev.kind { + case sessionEventCriticalExport: + if ev.closed || ev.msg == nil { + return + } + s.handleCriticalExportEvent(ev.idx, ev.msg) + s.tickReady(now) + case sessionEventExport: + if ev.closed || ev.msg == nil { + return + } + s.handleExportEvent(ev.msg) + case sessionEventOutboundCall: + if ev.closed || ev.msg == nil { + return + } + s.handleOutboundCallEvent(now, ev.msg) + case sessionEventInboundReply: + s.handleInboundReplyEvent(ev.callID, ev.msg, ev.closed) + } +} + func (s *session) setupExports() { if s.conn == nil { return } - for _, p := range criticalExportTopics { - s.criticalExportSubs = append(s.criticalExportSubs, s.conn.Subscribe(p)) + for i, p := range criticalExportTopics { + sub := s.conn.Subscribe(p) + s.criticalExportSubs = append(s.criticalExportSubs, sub) s.criticalExportReplayPending = append(s.criticalExportReplayPending, true) + s.criticalExportPendingMsgs = append(s.criticalExportPendingMsgs, nil) + s.watchSubscription(sub, sessionEventCriticalExport, i, "") } for _, p := range exportPatterns() { - s.exportSubs = append(s.exportSubs, s.conn.Subscribe(p)) + sub := s.conn.Subscribe(p) + s.exportSubs = append(s.exportSubs, sub) + s.watchSubscription(sub, sessionEventExport, -1, "") } for _, p := range exportCallPatterns() { - s.exportCallSubs = append(s.exportCallSubs, s.conn.Subscribe(p)) + sub := s.conn.Subscribe(p) + s.exportCallSubs = append(s.exportCallSubs, sub) + s.watchSubscription(sub, sessionEventOutboundCall, -1, "") } } @@ -1122,6 +1194,8 @@ func (s *session) teardownExports() { } s.criticalExportSubs = nil s.criticalExportReplayPending = nil + s.criticalExportPendingMsgs = nil + s.exportPendingMsgs = nil for _, sub := range s.exportSubs { s.conn.Unsubscribe(sub) } @@ -1245,6 +1319,101 @@ func (s *session) drainCriticalExports(total *int) bool { return true } +func (s *session) exportCanSend(now time.Time) bool { + return s.link == linkUp && s.exportsEnabled && (s.exportReadyAt.IsZero() || !now.Before(s.exportReadyAt)) +} + +func (s *session) queueCriticalExport(idx int, m *bus.Message) { + if idx < 0 || idx >= len(s.criticalExportPendingMsgs) { + return + } + s.criticalExportPendingMsgs[idx] = m +} + +func (s *session) queueExport(m *bus.Message) { + if m == nil { + return + } + // Keep the queue bounded. The bus subscription itself coalesces retained + // changes, but once a watcher has handed the event to the session we still + // avoid unbounded growth during handshake holdoff. + const maxPendingExports = 32 + if len(s.exportPendingMsgs) >= maxPendingExports { + copy(s.exportPendingMsgs, s.exportPendingMsgs[1:]) + s.exportPendingMsgs[len(s.exportPendingMsgs)-1] = m + return + } + s.exportPendingMsgs = append(s.exportPendingMsgs, m) +} + +func (s *session) handleCriticalExportEvent(idx int, m *bus.Message) { + if idx < 0 || idx >= len(s.criticalExportReplayPending) { + return + } + if !s.exportCanSend(time.Now()) { + s.queueCriticalExport(idx, m) + return + } + sent, ok := s.sendExportMessage(m) + if !ok { + s.queueCriticalExport(idx, m) + return + } + if sent && s.criticalExportReplayPending[idx] { + s.criticalExportReplayPending[idx] = false + } + s.criticalExportPendingMsgs[idx] = nil + s.drainQueuedExports() +} + +func (s *session) handleExportEvent(m *bus.Message) { + if !s.exportCanSend(time.Now()) || !s.criticalExportReplayDrained() { + s.queueExport(m) + return + } + if len(s.criticalExportSubs) > 0 && isCriticalExportTopic(m.Topic) { + return + } + _, ok := s.sendExportMessage(m) + if !ok { + s.queueExport(m) + } +} + +func (s *session) drainQueuedExports() { + if !s.exportCanSend(time.Now()) { + return + } + for i, m := range s.criticalExportPendingMsgs { + if m == nil || (i < len(s.criticalExportReplayPending) && !s.criticalExportReplayPending[i]) { + continue + } + sent, ok := s.sendExportMessage(m) + if !ok { + return + } + if sent && i < len(s.criticalExportReplayPending) { + s.criticalExportReplayPending[i] = false + s.criticalExportPendingMsgs[i] = nil + } + } + if !s.criticalExportReplayDrained() { + return + } + for len(s.exportPendingMsgs) > 0 { + m := s.exportPendingMsgs[0] + s.exportPendingMsgs = s.exportPendingMsgs[1:] + if m == nil || (len(s.criticalExportSubs) > 0 && isCriticalExportTopic(m.Topic)) { + continue + } + _, ok := s.sendExportMessage(m) + if !ok { + s.exportPendingMsgs = append([]*bus.Message{m}, s.exportPendingMsgs...) + return + } + } +} + // drainExports does a non-blocking read of each export subscription // and writes any messages to the wire. Called from the main loop. func (s *session) drainExports() { @@ -1293,135 +1462,144 @@ func (s *session) drainExports() { } } -func (s *session) drainInbound(now time.Time) { - if len(s.inboundCalls) == 0 { +func (s *session) findInboundCall(id string) (*inboundCall, int) { + for i, call := range s.inboundCalls { + if call.id == id { + return call, i + } + } + return nil, -1 +} + +func (s *session) removeInboundCall(idx int) { + if idx < 0 || idx >= len(s.inboundCalls) { return } + s.inboundCalls = append(s.inboundCalls[:idx], s.inboundCalls[idx+1:]...) +} +func (s *session) handleInboundReplyEvent(id string, reply *bus.Message, closed bool) { + call, idx := s.findInboundCall(id) + if call == nil { + return + } + if call.sub != nil { + s.conn.Unsubscribe(call.sub) + call.sub = nil + } + s.removeInboundCall(idx) + if closed || reply == nil { + sent := s.sendRPC(marshal(protoReply{Type: msgReply, Corr: call.id, OK: false, Err: reasonTimeout})) + s.rpcDiagInbound("call_reply_tx", call, false, reasonTimeout, otadiag.KV("sent", sent)) + return + } + if errStr := checkBusError(reply.Payload); errStr != "" { + sent := s.sendRPC(marshal(protoReply{Type: msgReply, Corr: call.id, OK: false, Err: errStr})) + s.rpcDiagInbound("call_reply_tx", call, false, errStr, otadiag.KV("sent", sent)) + return + } + payload, err := marshalPayload(reply.Payload) + if err != nil { + sent := s.sendRPC(marshal(protoReply{Type: msgReply, Corr: call.id, OK: false, Err: errPayloadMarshal})) + s.rpcDiagInbound("call_reply_tx", call, false, errPayloadMarshal, otadiag.KV("sent", sent)) + return + } + sent := s.sendRPC(marshal(protoReply{Type: msgReply, Corr: call.id, OK: true, Payload: payload})) + s.rpcDiagInbound("call_reply_tx", call, true, "", otadiag.KV("sent", sent)) +} + +func (s *session) expireInbound(now time.Time) { + if len(s.inboundCalls) == 0 { + return + } keep := s.inboundCalls[:0] for _, call := range s.inboundCalls { - select { - case reply, ok := <-call.sub.Channel(): - s.conn.Unsubscribe(call.sub) - call.sub = nil // prevent double-unsubscribe in teardownInbound - if !ok || reply == nil { - sent := s.sendRPC(marshal(protoReply{Type: msgReply, Corr: call.id, OK: false, Err: reasonTimeout})) - s.rpcDiagInbound("call_reply_tx", call, false, reasonTimeout, otadiag.KV("sent", sent)) - if !sent { - return - } - continue - } - if errStr := checkBusError(reply.Payload); errStr != "" { - sent := s.sendRPC(marshal(protoReply{Type: msgReply, Corr: call.id, OK: false, Err: errStr})) - s.rpcDiagInbound("call_reply_tx", call, false, errStr, otadiag.KV("sent", sent)) - if !sent { - return - } - continue - } - payload, err := marshalPayload(reply.Payload) - if err != nil { - sent := s.sendRPC(marshal(protoReply{Type: msgReply, Corr: call.id, OK: false, Err: errPayloadMarshal})) - s.rpcDiagInbound("call_reply_tx", call, false, errPayloadMarshal, otadiag.KV("sent", sent)) - if !sent { - return - } - continue - } - sent := s.sendRPC(marshal(protoReply{Type: msgReply, Corr: call.id, OK: true, Payload: payload})) - s.rpcDiagInbound("call_reply_tx", call, true, "", otadiag.KV("sent", sent)) - if !sent { - return - } - continue - default: - } - if !now.Before(call.deadline) { - s.conn.Unsubscribe(call.sub) - call.sub = nil + if call.sub != nil { + s.conn.Unsubscribe(call.sub) + call.sub = nil + } sent := s.sendRPC(marshal(protoReply{Type: msgReply, Corr: call.id, OK: false, Err: reasonTimeout})) s.rpcDiagInbound("call_reply_tx", call, false, reasonTimeout, otadiag.KV("sent", sent)) - if !sent { - return - } continue } - keep = append(keep, call) } - s.inboundCalls = keep } -func (s *session) drainOutbound(now time.Time) { - // Forward new outgoing calls from the local bus onto the wire. - if s.link == linkUp && len(s.exportCallSubs) > 0 { - for _, sub := range s.exportCallSubs { - for { - select { - case msg, ok := <-sub.Channel(): - if !ok || msg == nil { - goto nextSub - } - - wireTopic := exportCallTopic(msg.Topic) - if wireTopic == nil { - continue - } - - payload, err := marshalPayload(msg.Payload) - if err != nil { - s.logKV("outgoing call dropped", "err", err.Error()) - if msg.CanReply() { - s.conn.Reply(msg, types.ErrorReply{OK: false, Error: errPayloadMarshal}, false) - } - continue - } - id := s.nextOutboundID - s.nextOutboundID++ - corr := "wire-" + strconvx.Utoa64(id) - if msg.CanReply() { - s.outboundCalls = append(s.outboundCalls, &outboundCall{ - id: corr, - req: msg, - deadline: now.Add(callTimeoutDef), - }) - } - if !s.sendRPC(marshal(protoCall{ - Type: msgCall, - ID: corr, - Topic: wireTopic, - Payload: payload, - TimeoutMs: int(callTimeoutDef / time.Millisecond), - })) { - return - } - default: - goto nextSub - } - } - nextSub: +func (s *session) drainInbound(now time.Time) { + // Test/support path: the reactor receives inbound replies through + // sessionEventInboundReply. Direct calls still drain ready replies so + // unit tests can exercise the reducer without running the event loop. + calls := append([]*inboundCall(nil), s.inboundCalls...) + for _, call := range calls { + if call == nil || call.sub == nil { + continue + } + select { + case reply, ok := <-call.sub.Channel(): + s.handleInboundReplyEvent(call.id, reply, !ok) + default: } } + s.expireInbound(now) +} - // Expire outbound calls that have timed out waiting for a remote reply. - if len(s.outboundCalls) > 0 { - keep := s.outboundCalls[:0] - for _, call := range s.outboundCalls { - if !now.Before(call.deadline) { - if call.req != nil && call.req.CanReply() { - s.conn.Reply(call.req, types.ErrorReply{OK: false, Error: reasonTimeout}, false) - } - continue +func (s *session) handleOutboundCallEvent(now time.Time, msg *bus.Message) { + if s.link != linkUp || msg == nil { + return + } + wireTopic := exportCallTopic(msg.Topic) + if wireTopic == nil { + return + } + payload, err := marshalPayload(msg.Payload) + if err != nil { + s.logKV("outgoing call dropped", "err", err.Error()) + if msg.CanReply() { + s.conn.Reply(msg, types.ErrorReply{OK: false, Error: errPayloadMarshal}, false) + } + return + } + id := s.nextOutboundID + s.nextOutboundID++ + corr := "wire-" + strconvx.Utoa64(id) + if msg.CanReply() { + s.outboundCalls = append(s.outboundCalls, &outboundCall{ + id: corr, + req: msg, + deadline: now.Add(callTimeoutDef), + }) + } + _ = s.sendRPC(marshal(protoCall{ + Type: msgCall, + ID: corr, + Topic: wireTopic, + Payload: payload, + TimeoutMs: int(callTimeoutDef / time.Millisecond), + })) +} + +func (s *session) expireOutbound(now time.Time) { + if len(s.outboundCalls) == 0 { + return + } + keep := s.outboundCalls[:0] + for _, call := range s.outboundCalls { + if !now.Before(call.deadline) { + if call.req != nil && call.req.CanReply() { + s.conn.Reply(call.req, types.ErrorReply{OK: false, Error: reasonTimeout}, false) } - keep = append(keep, call) + continue } - s.outboundCalls = keep + keep = append(keep, call) } + s.outboundCalls = keep } +func (s *session) drainOutbound(now time.Time) { s.expireOutbound(now) } + // ---- transport write ---- // sendControl, sendRPC, sendBulk are the lane-tagged enqueue entry diff --git a/services/fabric/transfer.go b/services/fabric/transfer.go index 35b918d..e66fdbf 100644 --- a/services/fabric/transfer.go +++ b/services/fabric/transfer.go @@ -153,6 +153,22 @@ func (s *session) nextPendingDeadline(now time.Time) (time.Time, bool) { if call := s.pendingTargetCall; call != nil && !call.deadline.IsZero() { out, ok = earlierDeadline(out, ok, call.deadline, true) } + for _, call := range s.inboundCalls { + if !call.deadline.IsZero() { + out, ok = earlierDeadline(out, ok, call.deadline, true) + } + } + for _, call := range s.outboundCalls { + if !call.deadline.IsZero() { + out, ok = earlierDeadline(out, ok, call.deadline, true) + } + } + if s.link == linkUp && !s.nextPingAt.IsZero() { + out, ok = earlierDeadline(out, ok, s.nextPingAt, true) + } + if s.link == linkUp && !s.rpcReady && !s.exportReadyAt.IsZero() { + out, ok = earlierDeadline(out, ok, s.exportReadyAt, true) + } return out, ok } @@ -162,6 +178,11 @@ func (s *session) handlePendingDeadline(now time.Time) { if call != nil && !now.Before(call.deadline) { s.finishTargetCall(call, false, "stage_timeout") } + s.expireInbound(now) + s.expireOutbound(now) + s.tickPing(now) + s.tickReady(now) + s.drainQueuedExports() } func sameTransferTuple(a, b transferMeta) bool { From b48141bfd2f22f083eb0070845a09e74a4be4725 Mon Sep 17 00:00:00 2001 From: Rich Thanki Date: Mon, 8 Jun 2026 05:37:26 +0000 Subject: [PATCH 06/17] adds bus.SubscriptionSet, letting a reactor subscribe to several bus topics and wait on a single coalesced readiness channel --- bus/bus.go | 153 +++++++++++++++++++++++++++++++++- bus/bus_test.go | 52 ++++++++++++ services/fabric/session.go | 161 +++++++++++++++++++----------------- services/fabric/transfer.go | 6 +- 4 files changed, 291 insertions(+), 81 deletions(-) diff --git a/bus/bus.go b/bus/bus.go index c45894a..bfbbba9 100644 --- a/bus/bus.go +++ b/bus/bus.go @@ -165,6 +165,7 @@ type Subscription struct { ch chan *Message bus *Bus conn *Connection + set *SubscriptionSet } func (s *Subscription) Topic() Topic { return s.topic } @@ -176,6 +177,137 @@ func (s *Subscription) Reply(to *Message, payload any, retained bool) { s.conn.Reply(to, payload, retained) } +// ----------------------------------------------------------------------------- +// SubscriptionSet +// ----------------------------------------------------------------------------- + +// SubscriptionSet lets a reactor wait on one readiness channel for several +// subscriptions without spawning a goroutine per subscription. Readiness is +// coalesced: once Ready() is signalled, callers should drain the subscriptions +// they own until no immediate work remains. +type SubscriptionSet struct { + conn *Connection + ready chan struct{} + mu sync.Mutex + subs []*Subscription + closed bool +} + +func (c *Connection) NewSubscriptionSet() *SubscriptionSet { + return &SubscriptionSet{ + conn: c, + ready: make(chan struct{}, 1), + } +} + +func (ss *SubscriptionSet) Ready() <-chan struct{} { + if ss == nil { + return nil + } + return ss.ready +} + +func (ss *SubscriptionSet) Subscribe(tp Topic) *Subscription { + if ss == nil || ss.conn == nil { + return nil + } + ss.mu.Lock() + if ss.closed { + ss.mu.Unlock() + return nil + } + ss.mu.Unlock() + ct := toConcrete(tp) + sub := &Subscription{topic: ct, ch: make(chan *Message, ss.conn.bus.qLen), bus: ss.conn.bus, conn: ss.conn, set: ss} + ss.conn.bus.addSubscription(ct, sub) + ss.conn.mu.Lock() + ss.conn.subs = append(ss.conn.subs, sub) + ss.conn.mu.Unlock() + ss.mu.Lock() + if !ss.closed { + ss.subs = append(ss.subs, sub) + } else { + sub.set = nil + } + ss.mu.Unlock() + if sub.set == nil { + ss.conn.Unsubscribe(sub) + return nil + } + return sub +} + +func (ss *SubscriptionSet) Request(msg *Message) *Subscription { + if ss == nil || ss.conn == nil { + return nil + } + if topicLen(msg.ReplyTo) == 0 { + msg.ReplyTo = TNoIntern("_rr", ss.conn.rrCtr.Add(1)) + } + sub := ss.Subscribe(msg.ReplyTo) + ss.conn.Publish(msg) + return sub +} + +func (ss *SubscriptionSet) Unsubscribe(sub *Subscription) { + if ss == nil || sub == nil || ss.conn == nil { + return + } + ss.conn.Unsubscribe(sub) +} + +func (ss *SubscriptionSet) Close() { + if ss == nil || ss.conn == nil { + return + } + ss.mu.Lock() + if ss.closed { + ss.mu.Unlock() + return + } + subs := append([]*Subscription(nil), ss.subs...) + ss.subs = nil + ss.closed = true + close(ss.ready) + ss.mu.Unlock() + for _, sub := range subs { + ss.conn.Unsubscribe(sub) + } +} + +func (ss *SubscriptionSet) remove(sub *Subscription) { + if ss == nil || sub == nil { + return + } + ss.mu.Lock() + ss.subs = removeSub(ss.subs, sub) + ss.mu.Unlock() +} + +func (ss *SubscriptionSet) signal() { + if ss == nil { + return + } + ss.mu.Lock() + ready := ss.ready + closed := ss.closed + ss.mu.Unlock() + if closed || ready == nil { + return + } + defer func() { _ = recover() }() + select { + case ready <- struct{}{}: + default: + } +} + +func (s *Subscription) signalReady() { + if s != nil && s.set != nil { + s.set.signal() + } +} + // ----------------------------------------------------------------------------- // Trie node (shared for subscribers and retained messages) // ----------------------------------------------------------------------------- @@ -308,10 +440,12 @@ func drainOne(ch chan *Message) { func (b *Bus) tryDeliver(sub *Subscription, msg *Message) { defer func() { _ = recover() }() // channel may be closed; best-effort if trySend(sub.ch, msg) { + sub.signalReady() return } drainOne(sub.ch) _ = trySend(sub.ch, msg) + sub.signalReady() } // ----------------------------------------------------------------------------- @@ -489,10 +623,19 @@ func (c *Connection) Subscribe(tp Topic) *Subscription { } func (c *Connection) Unsubscribe(sub *Subscription) { + if sub == nil { + return + } c.bus.unsubscribe(sub.topic, sub) c.mu.Lock() c.subs = removeSub(c.subs, sub) c.mu.Unlock() + if sub.set != nil { + set := sub.set + sub.set = nil + set.remove(sub) + } + defer func() { _ = recover() }() close(sub.ch) } @@ -504,7 +647,15 @@ func (c *Connection) Disconnect() { for _, sub := range subs { c.bus.unsubscribe(sub.topic, sub) - close(sub.ch) + if sub.set != nil { + set := sub.set + sub.set = nil + set.remove(sub) + } + func(ch chan *Message) { + defer func() { _ = recover() }() + close(ch) + }(sub.ch) } } diff --git a/bus/bus_test.go b/bus/bus_test.go index f1de6e4..f8415d1 100644 --- a/bus/bus_test.go +++ b/bus/bus_test.go @@ -347,3 +347,55 @@ func TestTopic_InvalidTokenPanics(t *testing.T) { // []byte is not comparable, so T should panic _ = T([]byte{1, 2, 3}) } + +func TestSubscriptionSetSignalsForAnyMember(t *testing.T) { + b := NewBus(4, "+", "#") + c := b.NewConnection("test") + ss := c.NewSubscriptionSet() + defer ss.Close() + + a := ss.Subscribe(T("a")) + bb := ss.Subscribe(T("b")) + + c.Publish(c.NewMessage(T("b"), "bee", false)) + select { + case <-ss.Ready(): + case <-time.After(100 * time.Millisecond): + t.Fatal("timed out waiting for subscription set readiness") + } + + select { + case m := <-bb.Channel(): + if m.Payload != "bee" { + t.Fatalf("unexpected b payload: %#v", m.Payload) + } + default: + t.Fatal("b subscription was not ready after set signal") + } + + select { + case m := <-a.Channel(): + t.Fatalf("unexpected a message: %#v", m) + default: + } +} + +func TestSubscriptionSetCoalescesReadiness(t *testing.T) { + b := NewBus(4, "+", "#") + c := b.NewConnection("test") + ss := c.NewSubscriptionSet() + defer ss.Close() + + sub := ss.Subscribe(T("a")) + c.Publish(c.NewMessage(T("a"), "one", false)) + c.Publish(c.NewMessage(T("a"), "two", false)) + + select { + case <-ss.Ready(): + case <-time.After(100 * time.Millisecond): + t.Fatal("timed out waiting for first readiness") + } + + got := drainPayloads(t, sub, 2) + assertUnorderedEqual(t, got, []string{"one", "two"}) +} diff --git a/services/fabric/session.go b/services/fabric/session.go index f91e8ee..e65b890 100644 --- a/services/fabric/session.go +++ b/services/fabric/session.go @@ -90,24 +90,6 @@ type readResult struct { err error } -type sessionEventKind uint8 - -const ( - sessionEventCriticalExport sessionEventKind = iota + 1 - sessionEventExport - sessionEventOutboundCall - sessionEventInboundReply -) - -type sessionEvent struct { - kind sessionEventKind - msg *bus.Message - sub *bus.Subscription - idx int - callID string - closed bool -} - type linkStatePayload struct { LinkID string `json:"link_id"` Status string `json:"status"` @@ -149,6 +131,7 @@ type session struct { lastTxAt time.Time lastPongAt time.Time exportReadyAt time.Time + exportDrainAt time.Time exportsEnabled bool criticalExportSubs []*bus.Subscription @@ -170,7 +153,7 @@ type session struct { completedTransfers []completedTransfer pendingTargetCall *pendingTargetCall beginTransfer func(transferMeta) (transferSink, error) - events chan sessionEvent + busSubs *bus.SubscriptionSet ctx context.Context } @@ -192,7 +175,7 @@ func (s *session) logKV(msg, key, value string) { func (s *session) run(ctx context.Context) { s.cfg.applyDefaults() s.ctx = ctx - s.events = make(chan sessionEvent, 64) + s.busSubs = s.conn.NewSubscriptionSet() lines := make(chan readResult, lineQueueSize) go func() { @@ -249,6 +232,11 @@ func (s *session) run(ctx context.Context) { }() defer s.tr.Close() + defer func() { + if s.busSubs != nil { + s.busSubs.Close() + } + }() defer s.teardownExports() defer s.teardownInbound() defer s.teardownOutbound(reasonLinkDown) @@ -307,8 +295,11 @@ func (s *session) run(ctx context.Context) { case <-pendingDeadlineCh: s.handlePendingDeadline(time.Now()) - case ev := <-s.events: - s.handleSessionEvent(time.Now(), ev) + case _, ok := <-s.busReady(): + if !ok { + return + } + s.drainBusEvents(time.Now()) case <-waitTick.C: s.logWaiting() @@ -423,6 +414,7 @@ func (s *session) handleLinkDown(reason, err string) { s.peerSID = "" s.peerProto = "" s.exportReadyAt = time.Time{} + s.exportDrainAt = time.Time{} s.exportsEnabled = false s.rpcReady = false s.teardownExports() @@ -469,6 +461,7 @@ func (s *session) promoteLink(reason string) { s.setupExports() s.exportsEnabled = true s.exportReadyAt = time.Now().Add(exportStartHoldoff) + s.exportDrainAt = time.Time{} s.nextPingAt = time.Now().Add(s.cfg.PingInterval) s.log("exports enabled") s.publishLinkState(reason, "") @@ -512,6 +505,7 @@ func (s *session) tickReady(now time.Time) { return } if !s.criticalExportReplayDrained() { + s.scheduleExportDrain(now) return } s.rpcReady = true @@ -1033,7 +1027,7 @@ func (s *session) onCall(msg *protoCall) { s.rpcDiag("call_dispatch_start", msg, localTopic, "", otadiag.KV("timeout_ms", strconvx.Itoa(int(timeout/time.Millisecond))), ) - sub := s.conn.Request(busMsg) + sub := s.requestBus(busMsg) topicCopy := append([]string(nil), msg.Topic...) call := &inboundCall{ id: msg.ID, @@ -1044,7 +1038,6 @@ func (s *session) onCall(msg *protoCall) { deadline: time.Now().Add(timeout), } s.inboundCalls = append(s.inboundCalls, call) - s.watchSubscription(sub, sessionEventInboundReply, -1, msg.ID) } func (s *session) onReply(msg *protoReply) { @@ -1118,73 +1111,55 @@ func marshalPayload(payload any) (json.RawMessage, error) { // Exports are drained inline in the main loop (no extra goroutines) // to avoid TinyGo cooperative scheduler mutex panics. -func (s *session) watchSubscription(sub *bus.Subscription, kind sessionEventKind, idx int, callID string) { - if sub == nil || s.ctx == nil || s.events == nil { - return +func (s *session) busReady() <-chan struct{} { + if s.busSubs == nil { + return nil } - ctx := s.ctx - go func() { - for { - select { - case m, ok := <-sub.Channel(): - ev := sessionEvent{kind: kind, sub: sub, idx: idx, callID: callID, msg: m, closed: !ok} - select { - case s.events <- ev: - case <-ctx.Done(): - } - if !ok { - return - } - case <-ctx.Done(): - return - } - } - }() + return s.busSubs.Ready() } -func (s *session) handleSessionEvent(now time.Time, ev sessionEvent) { - switch ev.kind { - case sessionEventCriticalExport: - if ev.closed || ev.msg == nil { - return - } - s.handleCriticalExportEvent(ev.idx, ev.msg) - s.tickReady(now) - case sessionEventExport: - if ev.closed || ev.msg == nil { - return - } - s.handleExportEvent(ev.msg) - case sessionEventOutboundCall: - if ev.closed || ev.msg == nil { - return - } - s.handleOutboundCallEvent(now, ev.msg) - case sessionEventInboundReply: - s.handleInboundReplyEvent(ev.callID, ev.msg, ev.closed) +func (s *session) subscribeBus(tp bus.Topic) *bus.Subscription { + if s.busSubs == nil { + return s.conn.Subscribe(tp) } + return s.busSubs.Subscribe(tp) +} + +func (s *session) requestBus(msg *bus.Message) *bus.Subscription { + if s.busSubs == nil { + return s.conn.Request(msg) + } + return s.busSubs.Request(msg) +} + +func (s *session) drainBusEvents(now time.Time) { + // A single bus readiness edge may cover several subscriptions. Drain each + // class without blocking; export drains retain their quota and will be + // resumed by the export-ready timer when work remains. + s.drainExports() + s.drainOutboundMessages(now) + s.drainInbound(now) + s.tickReady(now) + s.drainQueuedExports() } func (s *session) setupExports() { if s.conn == nil { return } - for i, p := range criticalExportTopics { - sub := s.conn.Subscribe(p) + for _, p := range criticalExportTopics { + sub := s.subscribeBus(p) s.criticalExportSubs = append(s.criticalExportSubs, sub) s.criticalExportReplayPending = append(s.criticalExportReplayPending, true) s.criticalExportPendingMsgs = append(s.criticalExportPendingMsgs, nil) - s.watchSubscription(sub, sessionEventCriticalExport, i, "") } for _, p := range exportPatterns() { - sub := s.conn.Subscribe(p) + sub := s.subscribeBus(p) s.exportSubs = append(s.exportSubs, sub) - s.watchSubscription(sub, sessionEventExport, -1, "") } for _, p := range exportCallPatterns() { - sub := s.conn.Subscribe(p) + sub := s.subscribeBus(p) s.exportCallSubs = append(s.exportCallSubs, sub) - s.watchSubscription(sub, sessionEventOutboundCall, -1, "") } } @@ -1381,7 +1356,8 @@ func (s *session) handleExportEvent(m *bus.Message) { } func (s *session) drainQueuedExports() { - if !s.exportCanSend(time.Now()) { + now := time.Now() + if !s.exportCanSend(now) { return } for i, m := range s.criticalExportPendingMsgs { @@ -1398,6 +1374,7 @@ func (s *session) drainQueuedExports() { } } if !s.criticalExportReplayDrained() { + s.scheduleExportDrain(now) return } for len(s.exportPendingMsgs) > 0 { @@ -1414,13 +1391,21 @@ func (s *session) drainQueuedExports() { } } +func (s *session) scheduleExportDrain(now time.Time) { + when := now.Add(time.Millisecond) + if s.exportDrainAt.IsZero() || when.Before(s.exportDrainAt) { + s.exportDrainAt = when + } +} + // drainExports does a non-blocking read of each export subscription // and writes any messages to the wire. Called from the main loop. func (s *session) drainExports() { + now := time.Now() + s.exportDrainAt = time.Time{} if s.link != linkUp { return } - now := time.Now() if !s.exportsEnabled { return } @@ -1432,11 +1417,13 @@ func (s *session) drainExports() { return } if !s.criticalExportReplayDrained() { + s.scheduleExportDrain(now) return } for _, sub := range s.exportSubs { for { if total >= exportMaxPerTick { + s.scheduleExportDrain(now) return } select { @@ -1529,9 +1516,9 @@ func (s *session) expireInbound(now time.Time) { } func (s *session) drainInbound(now time.Time) { - // Test/support path: the reactor receives inbound replies through - // sessionEventInboundReply. Direct calls still drain ready replies so - // unit tests can exercise the reducer without running the event loop. + // Reactor path: bus readiness is coalesced by SubscriptionSet, then this + // reducer drains all ready inbound replies without blocking. Direct calls + // still let unit tests exercise the reducer without running the event loop. calls := append([]*inboundCall(nil), s.inboundCalls...) for _, call := range calls { if call == nil || call.sub == nil { @@ -1598,7 +1585,25 @@ func (s *session) expireOutbound(now time.Time) { s.outboundCalls = keep } -func (s *session) drainOutbound(now time.Time) { s.expireOutbound(now) } +func (s *session) drainOutboundMessages(now time.Time) { + for _, sub := range s.exportCallSubs { + for { + select { + case msg, ok := <-sub.Channel(): + if !ok || msg == nil { + goto nextSub + } + s.handleOutboundCallEvent(now, msg) + default: + goto nextSub + } + } + nextSub: + } + s.expireOutbound(now) +} + +func (s *session) drainOutbound(now time.Time) { s.drainOutboundMessages(now) } // ---- transport write ---- diff --git a/services/fabric/transfer.go b/services/fabric/transfer.go index e66fdbf..85dee3a 100644 --- a/services/fabric/transfer.go +++ b/services/fabric/transfer.go @@ -169,6 +169,9 @@ func (s *session) nextPendingDeadline(now time.Time) (time.Time, bool) { if s.link == linkUp && !s.rpcReady && !s.exportReadyAt.IsZero() { out, ok = earlierDeadline(out, ok, s.exportReadyAt, true) } + if s.link == linkUp && !s.exportDrainAt.IsZero() { + out, ok = earlierDeadline(out, ok, s.exportDrainAt, true) + } return out, ok } @@ -181,8 +184,7 @@ func (s *session) handlePendingDeadline(now time.Time) { s.expireInbound(now) s.expireOutbound(now) s.tickPing(now) - s.tickReady(now) - s.drainQueuedExports() + s.drainBusEvents(now) } func sameTransferTuple(a, b transferMeta) bool { From f58928fa955ab530784603c42a100349f03c4e65 Mon Sep 17 00:00:00 2001 From: Rich Thanki Date: Mon, 8 Jun 2026 05:59:05 +0000 Subject: [PATCH 07/17] adds a transferSinkWorker as the sole owner of the transfer sink --- services/fabric/fabric_test.go | 8 +- services/fabric/session.go | 4 +- services/fabric/transfer.go | 268 +++++++++++++++++++++++++------ services/fabric/transfer_test.go | 41 +++-- 4 files changed, 252 insertions(+), 69 deletions(-) diff --git a/services/fabric/fabric_test.go b/services/fabric/fabric_test.go index dd3a1c7..3222a6a 100644 --- a/services/fabric/fabric_test.go +++ b/services/fabric/fabric_test.go @@ -426,8 +426,8 @@ func TestDuplicateSameSIDHelloRefreshesWithoutReset(t *testing.T) { peerSID: "s1", peerNode: "bigbox-cm5", incomingTransfer: &incomingTransfer{ - meta: transferMeta{ID: "xfer-1"}, - sink: sink, + meta: transferMeta{ID: "xfer-1"}, + worker: newTransferSinkWorker("xfer-1", sink), }, } @@ -463,8 +463,8 @@ func TestDuplicateSameSIDHelloAckRefreshesWithoutReset(t *testing.T) { peerSID: "s1", peerNode: "bigbox-cm5", incomingTransfer: &incomingTransfer{ - meta: transferMeta{ID: "xfer-1"}, - sink: sink, + meta: transferMeta{ID: "xfer-1"}, + worker: newTransferSinkWorker("xfer-1", sink), }, } diff --git a/services/fabric/session.go b/services/fabric/session.go index e65b890..cf7dcae 100644 --- a/services/fabric/session.go +++ b/services/fabric/session.go @@ -283,8 +283,8 @@ func (s *session) run(ctx context.Context) { resetTimer(stale, s.cfg.LivenessTimeout) } - case err := <-s.pendingChunkReady(): - s.finishChunkWrite(time.Now(), err) + case res := <-s.pendingChunkReady(): + s.finishChunkWrite(time.Now(), res) case res := <-s.pendingCommitReady(): s.finishTransferCommit(time.Now(), res) diff --git a/services/fabric/transfer.go b/services/fabric/transfer.go index 85dee3a..a6fbb03 100644 --- a/services/fabric/transfer.go +++ b/services/fabric/transfer.go @@ -3,6 +3,7 @@ package fabric import ( "encoding/base64" "encoding/json" + "errors" "runtime" "strings" "time" @@ -58,7 +59,7 @@ type transferSink interface { type incomingTransfer struct { meta transferMeta - sink transferSink + worker *transferSinkWorker bytesWritten uint32 chunksSeen uint32 hasher *xxhash.Hasher @@ -81,7 +82,11 @@ type pendingChunkWrite struct { offset uint32 data []byte started time.Time - resultCh chan error + resultCh chan transferChunkResult +} + +type transferChunkResult struct { + err error } type pendingTransferCommit struct { @@ -95,6 +100,189 @@ type transferCommitResult struct { err error } +type transferSinkCommandKind uint8 + +const ( + transferSinkCommandWrite transferSinkCommandKind = iota + 1 + transferSinkCommandCommit + transferSinkCommandAbort +) + +type transferSinkCommand struct { + kind transferSinkCommandKind + xferID string + offset uint32 + data []byte + reason string + timeout time.Duration + chunkResult chan<- transferChunkResult + commitResult chan<- transferCommitResult +} + +type transferSinkWorker struct { + xferID string + cmdCh chan transferSinkCommand +} + +func newTransferSinkWorker(xferID string, sink transferSink) *transferSinkWorker { + w := &transferSinkWorker{ + xferID: xferID, + cmdCh: make(chan transferSinkCommand, 1), + } + go w.run(sink) + return w +} + +func (w *transferSinkWorker) write(xferID string, offset uint32, data []byte, timeout time.Duration, result chan<- transferChunkResult) bool { + return w.send(transferSinkCommand{ + kind: transferSinkCommandWrite, + xferID: xferID, + offset: offset, + data: data, + timeout: timeout, + chunkResult: result, + }) +} + +func (w *transferSinkWorker) commit(xferID string, timeout time.Duration, result chan<- transferCommitResult) bool { + return w.send(transferSinkCommand{ + kind: transferSinkCommandCommit, + xferID: xferID, + timeout: timeout, + commitResult: result, + }) +} + +func (w *transferSinkWorker) abort(reason string) bool { + if reason == "" { + reason = "abort" + } + return w.send(transferSinkCommand{ + kind: transferSinkCommandAbort, + xferID: w.xferID, + reason: reason, + }) +} + +func (w *transferSinkWorker) send(cmd transferSinkCommand) bool { + select { + case w.cmdCh <- cmd: + return true + default: + return false + } +} + +func (w *transferSinkWorker) run(sink transferSink) { + for cmd := range w.cmdCh { + switch cmd.kind { + case transferSinkCommandWrite: + if !w.runWrite(sink, cmd) { + return + } + case transferSinkCommandCommit: + w.runCommit(sink, cmd) + return + case transferSinkCommandAbort: + _ = sink.Abort(cmd.reason) + return + } + } +} + +func (w *transferSinkWorker) runWrite(sink transferSink, cmd transferSinkCommand) bool { + opDone := make(chan error, 1) + go func() { + opDone <- sink.WriteChunk(cmd.offset, cmd.data) + }() + timer, timerCh := newOptionalWorkerTimer(cmd.timeout) + defer stopOptionalWorkerTimer(timer) + + select { + case err := <-opDone: + if err != nil { + _ = sink.Abort(err.Error()) + cmd.chunkResult <- transferChunkResult{err: err} + return false + } + gcStart := time.Now() + next := cmd.offset + uint32(len(cmd.data)) + otadiag.Event("[fabric-xfer]", "gc_start", cmd.xferID, otadiag.KV("next", u32s(next))) + runtime.GC() + otadiag.Event( + "[fabric-xfer]", "gc_done", cmd.xferID, + otadiag.KV("dur_ms", int(time.Since(gcStart)/time.Millisecond)), + otadiag.KV("next", next), + ) + cmd.chunkResult <- transferChunkResult{} + return true + case <-timerCh: + reason := "chunk_write_timeout" + cmd.chunkResult <- transferChunkResult{err: errors.New(reason)} + <-opDone + _ = sink.Abort(reason) + return false + case abort := <-w.cmdCh: + if abort.kind != transferSinkCommandAbort { + cmd.chunkResult <- transferChunkResult{err: errors.New("transfer_worker_protocol_error")} + return false + } + <-opDone + _ = sink.Abort(abort.reason) + return false + } +} + +func (w *transferSinkWorker) runCommit(sink transferSink, cmd transferSinkCommand) { + opDone := make(chan transferCommitResult, 1) + go func() { + info, err := sink.Commit() + opDone <- transferCommitResult{info: info, err: err} + }() + timer, timerCh := newOptionalWorkerTimer(cmd.timeout) + defer stopOptionalWorkerTimer(timer) + + select { + case res := <-opDone: + if res.err != nil { + _ = sink.Abort(res.err.Error()) + } + cmd.commitResult <- res + case <-timerCh: + reason := "transfer_commit_timeout" + cmd.commitResult <- transferCommitResult{err: errors.New(reason)} + <-opDone + _ = sink.Abort(reason) + case abort := <-w.cmdCh: + if abort.kind != transferSinkCommandAbort { + cmd.commitResult <- transferCommitResult{err: errors.New("transfer_worker_protocol_error")} + return + } + <-opDone + _ = sink.Abort(abort.reason) + } +} + +func newOptionalWorkerTimer(d time.Duration) (*time.Timer, <-chan time.Time) { + if d <= 0 { + return nil, nil + } + t := time.NewTimer(d) + return t, t.C +} + +func stopOptionalWorkerTimer(t *time.Timer) { + if t == nil { + return + } + if !t.Stop() { + select { + case <-t.C: + default: + } + } +} + type completedTransfer struct { meta transferMeta } @@ -107,7 +295,7 @@ type pendingTargetCall struct { deadline time.Time } -func (s *session) pendingChunkReady() <-chan error { +func (s *session) pendingChunkReady() <-chan transferChunkResult { cur := s.incomingTransfer if cur == nil || cur.pendingChunk == nil { return nil @@ -147,7 +335,7 @@ func earlierDeadline(a time.Time, aOK bool, b time.Time, bOK bool) (time.Time, b func (s *session) nextPendingDeadline(now time.Time) (time.Time, bool) { var out time.Time var ok bool - if cur := s.incomingTransfer; cur != nil && !cur.deadline.IsZero() { + if cur := s.incomingTransfer; cur != nil && cur.pendingChunk == nil && cur.pendingCommit == nil && !cur.deadline.IsZero() { out, ok = earlierDeadline(out, ok, cur.deadline, true) } if call := s.pendingTargetCall; call != nil && !call.deadline.IsZero() { @@ -282,9 +470,18 @@ func (s *session) abortTransfer(reason string) { } otadiag.Event("[fabric-xfer]", "abort_local", cur.meta.ID, otadiag.KV("reason", reason)) otadiag.StopUpdateWindow(reason) - if err := cur.sink.Abort(reason); err != nil { - s.logKV("transfer abort failed", "err", err.Error()) + if cur.worker != nil && !cur.worker.abort(reason) { + s.logKV("transfer abort enqueue failed", "reason", reason) + } +} + +func (s *session) clearTransferAfterWorkerFailure(reason string) { + cur := s.clearTransfer() + if cur == nil { + return } + otadiag.Event("[fabric-xfer]", "abort_local", cur.meta.ID, otadiag.KV("reason", reason), otadiag.KV("worker_owned", true)) + otadiag.StopUpdateWindow(reason) } // checkTransferTimeout enforces the idle-chunk watchdog. Fires once per @@ -300,18 +497,11 @@ func (s *session) checkTransferTimeout(now time.Time) { if !now.After(cur.deadline) { return } - if cur.pendingChunk != nil { - id := cur.meta.ID - s.abortTransfer("chunk_write_timeout") - abortOK := s.sendTransferAbort(id, "chunk_write_timeout") - otadiag.Event("[fabric-xfer]", "abort_tx", id, otadiag.KV("reason", "chunk_write_timeout"), otadiag.KV("ok", abortOK)) - return - } - if cur.pendingCommit != nil { - id := cur.meta.ID - s.abortTransfer("transfer_commit_timeout") - abortOK := s.sendTransferAbort(id, "transfer_commit_timeout") - otadiag.Event("[fabric-xfer]", "abort_tx", id, otadiag.KV("reason", "transfer_commit_timeout"), otadiag.KV("ok", abortOK)) + if cur.pendingChunk != nil || cur.pendingCommit != nil { + // Pending sink operations own their own deadlines. The worker reports a + // timeout event to the reactor, then aborts the sink after the in-flight + // sink method reaches a safe point. The reactor must not call Abort while + // WriteChunk or Commit may still be executing. return } if cur.idleRetries < transferIdleRetryLimit { @@ -529,7 +719,7 @@ func (s *session) onTransferBegin(msg *protoXferBegin) { ) s.incomingTransfer = &incomingTransfer{ meta: meta, - sink: sink, + worker: newTransferSinkWorker(meta.ID, sink), hasher: xxhash.New(0), deadline: now.Add(s.cfg.PhaseTimeout), } @@ -544,8 +734,7 @@ func (s *session) onTransferBegin(msg *protoXferBegin) { } func (s *session) startPendingChunkWrite(cur *incomingTransfer, offset uint32, raw []byte) { - ch := make(chan error, 1) - sink := cur.sink + ch := make(chan transferChunkResult, 1) data := raw started := time.Now() cur.pendingChunk = &pendingChunkWrite{ @@ -555,28 +744,27 @@ func (s *session) startPendingChunkWrite(cur *incomingTransfer, offset uint32, r started: started, resultCh: ch, } - cur.deadline = started.Add(s.cfg.PhaseTimeout) - go func() { - ch <- sink.WriteChunk(offset, data) - }() + if cur.worker == nil || !cur.worker.write(cur.meta.ID, offset, data, s.cfg.PhaseTimeout, ch) { + ch <- transferChunkResult{err: errors.New("transfer_worker_busy")} + } } -func (s *session) finishChunkWrite(now time.Time, err error) { +func (s *session) finishChunkWrite(now time.Time, res transferChunkResult) { cur := s.incomingTransfer if cur == nil || cur.pendingChunk == nil { return } pending := cur.pendingChunk cur.pendingChunk = nil - if err != nil { - reason := err.Error() + if res.err != nil { + reason := res.err.Error() otadiag.Event( "[fabric-xfer]", "sink_write_error", pending.xferID, otadiag.KV("reason", reason), otadiag.KV("dur_ms", int(time.Since(pending.started)/time.Millisecond)), ) s.logKV("transfer write failed", "err", reason) - s.abortTransfer(reason) + s.clearTransferAfterWorkerFailure(reason) abortOK := s.sendTransferAbort(pending.xferID, reason) otadiag.Event("[fabric-xfer]", "abort_tx", pending.xferID, otadiag.KV("reason", reason), otadiag.KV("ok", abortOK)) return @@ -594,17 +782,6 @@ func (s *session) finishChunkWrite(now time.Time, err error) { otadiag.KV("next", u32s(cur.bytesWritten)), ) pending.data = nil - // Keep transfer memory bounded on TinyGo. The receiver allocates while - // unmarshalling JSON and decoding base64 chunks; without regular collection - // long updates can run out of heap before commit. - gcStart := time.Now() - otadiag.Event("[fabric-xfer]", "gc_start", pending.xferID, otadiag.KV("next", u32s(cur.bytesWritten))) - runtime.GC() - otadiag.Event( - "[fabric-xfer]", "gc_done", pending.xferID, - otadiag.KV("dur_ms", int(time.Since(gcStart)/time.Millisecond)), - otadiag.KV("next", cur.bytesWritten), - ) needOK := s.sendTransferNeed(cur.meta.ID, cur.bytesWritten) otadiag.Event( "[fabric-xfer]", "need_tx", cur.meta.ID, @@ -626,18 +803,15 @@ func (s *session) finishChunkWrite(now time.Time, err error) { func (s *session) startPendingTransferCommit(cur *incomingTransfer) { ch := make(chan transferCommitResult, 1) - sink := cur.sink started := time.Now() cur.pendingCommit = &pendingTransferCommit{ xferID: cur.meta.ID, started: started, resultCh: ch, } - cur.deadline = started.Add(s.cfg.TargetCallTimeout) - go func() { - info, err := sink.Commit() - ch <- transferCommitResult{info: info, err: err} - }() + if cur.worker == nil || !cur.worker.commit(cur.meta.ID, s.cfg.TargetCallTimeout, ch) { + ch <- transferCommitResult{err: errors.New("transfer_worker_busy")} + } } func (s *session) finishTransferCommit(now time.Time, res transferCommitResult) { @@ -650,7 +824,7 @@ func (s *session) finishTransferCommit(now time.Time, res transferCommitResult) if res.err != nil { reason := res.err.Error() s.logKV("transfer commit failed", "err", reason) - s.abortTransfer(reason) + s.clearTransferAfterWorkerFailure(reason) abortOK := s.sendTransferAbort(pending.xferID, reason) otadiag.Event("[fabric-xfer]", "abort_tx", pending.xferID, otadiag.KV("reason", reason), otadiag.KV("ok", abortOK)) return diff --git a/services/fabric/transfer_test.go b/services/fabric/transfer_test.go index 85d1102..d06233e 100644 --- a/services/fabric/transfer_test.go +++ b/services/fabric/transfer_test.go @@ -65,6 +65,23 @@ func (s *fakeTransferSink) Abort(reason string) error { return nil } +func waitAbortReason(t *testing.T, sink *fakeTransferSink, want string) { + t.Helper() + deadline := time.Now().Add(time.Second) + for { + if len(sink.abortReasons) > 0 { + if want != "" && sink.abortReasons[0] != want { + t.Fatalf("sink.Abort reasons = %v, want %q", sink.abortReasons, want) + } + return + } + if time.Now().After(deadline) { + t.Fatalf("timed out waiting for sink.Abort(%q); reasons=%v", want, sink.abortReasons) + } + time.Sleep(time.Millisecond) + } +} + type diagCapture struct { mu sync.Mutex lines []string @@ -731,9 +748,9 @@ func TestTransferAcceptedChunkEmitsProcessingDiagnostics(t *testing.T) { []string{"[fabric-xfer]", "xfer_id xfer-chunk-diag", "ev chunk_decode_done", "ok true", "raw_len 4"}, []string{"[fabric-xfer]", "xfer_id xfer-chunk-diag", "ev chunk_digest_done", "ok true"}, []string{"[fabric-xfer]", "xfer_id xfer-chunk-diag", "ev sink_write_start", "offset 0", "raw_len 4"}, - []string{"[fabric-xfer]", "xfer_id xfer-chunk-diag", "ev sink_write_done", "next 4"}, []string{"[fabric-xfer]", "xfer_id xfer-chunk-diag", "ev gc_start", "next 4"}, []string{"[fabric-xfer]", "xfer_id xfer-chunk-diag", "ev gc_done", "next 4"}, + []string{"[fabric-xfer]", "xfer_id xfer-chunk-diag", "ev sink_write_done", "next 4"}, []string{"[fabric-xfer]", "xfer_id xfer-chunk-diag", "ev need_tx", "next 4", "ok true", "accepted true"}, ) assertDiagNotContains(t, diag.snapshot(), "[fabric-xfer]", "xfer_id xfer-chunk-diag", "ev transfer_mem_sample") @@ -918,7 +935,7 @@ func TestTransferStaleLowerOffsetDoesNotRefreshPhaseDeadline(t *testing.T) { cfg: LinkConfig{PhaseTimeout: time.Second}, incomingTransfer: &incomingTransfer{ meta: transferMeta{ID: "xfer-stale-deadline", Size: 6}, - sink: sink, + worker: newTransferSinkWorker("xfer-stale-deadline", sink), bytesWritten: 3, deadline: oldDeadline, }, @@ -953,7 +970,7 @@ func TestTransferCurrentCorruptChunkRefreshesLinkLiveness(t *testing.T) { cfg: LinkConfig{PhaseTimeout: time.Second}, incomingTransfer: &incomingTransfer{ meta: transferMeta{ID: "xfer-corrupt-liveness", Size: 4}, - sink: &fakeTransferSink{}, + worker: newTransferSinkWorker("xfer-corrupt-liveness", &fakeTransferSink{}), deadline: time.Now().Add(time.Second), }, } @@ -1048,9 +1065,7 @@ func TestTransferChunkMissingDigestRetriesThenAborts(t *testing.T) { Data: rawURL(payload), }) readTransferAbort(t, cm5, "xfer-missing-digest", "bad_message") - if len(sink.abortReasons) == 0 { - t.Fatal("expected sink.Abort on missing chunk digest") - } + waitAbortReason(t, sink, "bad_message") } func TestTransferChunkInvalidBase64RetriesThenAborts(t *testing.T) { @@ -1085,9 +1100,7 @@ func TestTransferChunkInvalidBase64RetriesThenAborts(t *testing.T) { ChunkDigest: xxhashStr(payload), }) readTransferAbort(t, cm5, "xfer-bad-b64", "invalid_chunk_encoding") - if len(sink.abortReasons) == 0 || sink.abortReasons[0] != "invalid_chunk_encoding" { - t.Fatalf("sink.Abort reasons = %v, want invalid_chunk_encoding", sink.abortReasons) - } + waitAbortReason(t, sink, "invalid_chunk_encoding") } func TestTransferChunkDigestMismatchRequestsSameOffset(t *testing.T) { @@ -1241,7 +1254,7 @@ func TestTransferMalformedWrongXferIDDoesNotChargeActiveTransfer(t *testing.T) { tr: tr, incomingTransfer: &incomingTransfer{ meta: transferMeta{ID: activeID}, - sink: sink, + worker: newTransferSinkWorker(activeID, sink), deadline: time.Now().Add(time.Second), }, } @@ -1461,9 +1474,7 @@ func TestTransferCommitDigestMismatchAborts(t *testing.T) { if abort.Type != msgXferAbort || abort.Err != "digest_mismatch" { t.Fatalf("bad xfer_abort: %+v", abort) } - if len(sink.abortReasons) == 0 { - t.Fatal("expected sink abort on digest mismatch") - } + waitAbortReason(t, sink, "digest_mismatch") } func TestTransferTargetInvokedAfterCommit(t *testing.T) { @@ -1776,9 +1787,7 @@ func TestTransferIdleChunkWatchdog(t *testing.T) { if abort.Type != msgXferAbort || abort.XferID != "xfer-wd" || abort.Err != "timeout" { t.Fatalf("bad xfer_abort: %+v", abort) } - if len(sink.abortReasons) == 0 || sink.abortReasons[0] != "timeout" { - t.Fatalf("sink.Abort reasons = %v, want [\"timeout\"]", sink.abortReasons) - } + waitAbortReason(t, sink, "timeout") } func TestTransferCommitDigestMismatchOnCommitFrameAborts(t *testing.T) { From f392b8e811ebbaf71d6a27404dbef0ed5621eec2 Mon Sep 17 00:00:00 2001 From: Rich Thanki Date: Tue, 9 Jun 2026 04:21:22 +0100 Subject: [PATCH 08/17] ready to test --- README.md | 2 +- bus/bus.go | 10 + cmd/fabric-selftest/README.md | 27 + cmd/fabric-selftest/main.go | 97 ++++ docs/colleague-hardware-test-plan.md | 159 ++++++ docs/fabric-update-gates.md | 81 +++ go.mod | 4 +- go.sum | 2 + main.go | 37 +- original.zip | Bin 0 -> 210046 bytes services/fabric/buffers.go | 47 ++ services/fabric/buffers_alloc_test.go | 86 +++ services/fabric/counters.go | 21 + services/fabric/fabric.go | 72 ++- services/fabric/fabric_test.go | 19 +- services/fabric/mcu_update_flow_test.go | 333 ++++++++++++ services/fabric/protocol.go | 369 ++++++++++++- services/fabric/selftest.go | 443 +++++++++++++++ services/fabric/selftest_test.go | 50 ++ services/fabric/session.go | 301 +++++++---- services/fabric/transfer.go | 191 +++---- services/fabric/transfer_sink.go | 67 +++ services/fabric/transfer_sink_rp2350.go | 58 -- services/fabric/transfer_sink_stub.go | 62 --- services/fabric/transfer_test.go | 128 +++-- services/fabric/transport_limits.go | 4 +- services/fabric/transport_shmring.go | 136 ++--- services/hal/devices/serial_raw/builder.go | 382 +++---------- .../hal/devices/serial_raw/builder_test.go | 269 ---------- .../hal/internal/provider/rp2_resources.go | 49 -- services/hal/internal/provider/setup_none.go | 2 +- .../provider/setups/pico_bb_proto_1.go | 4 +- services/otadiag/otadiag.go | 39 +- services/otadiag/otadiag_test.go | 19 +- services/reactor/build_policy_apply_test.go | 17 + .../build_policy_apply_without_stage_test.go | 14 + services/reactor/build_policy_default_test.go | 17 + .../reactor/build_policy_flash_stage_test.go | 17 + .../reactor/build_policy_selftest_test.go | 17 + .../reactor/build_policy_uart_hwtest_test.go | 17 + services/reactor/children.go | 163 ++++++ services/reactor/fabric_link.go | 96 ++++ services/reactor/fabric_selftest_disabled.go | 5 + services/reactor/fabric_selftest_enabled.go | 36 ++ services/reactor/fabric_stage_disabled.go | 54 ++ services/reactor/fabric_stage_flash.go | 14 + services/reactor/fabric_stage_hwtest.go | 14 + .../reactor/fabric_uart_policy_default.go | 5 + .../reactor/fabric_uart_policy_selftest.go | 5 + services/reactor/qa_reactor.go | 25 +- services/reactor/reactor.go | 381 ++++++------- services/reactor/reactor_test.go | 106 ---- services/reactor/updater_policy_apply.go | 18 + services/reactor/updater_policy_default.go | 17 + services/telemetry/telemetry.go | 11 + services/updater/abupdate_diag_host.go | 17 +- services/updater/abupdate_diag_tinygo.go | 7 + services/updater/applier_host.go | 2 +- services/updater/applier_tinygo.go | 2 +- services/updater/prestage_host.go | 3 +- services/updater/prestage_hwtest_tinygo.go | 108 ++++ .../updater/prestage_hwtest_types_tinygo.go | 16 + services/updater/prestage_tinygo.go | 6 +- services/updater/receiver.go | 2 +- services/updater/rpc.go | 16 +- services/updater/stream_lease.go | 506 ++++++++++++++---- services/updater/stream_stage_actor_test.go | 153 ++++++ services/updater/types.go | 21 +- services/updater/updater.go | 64 ++- services/updater/updater_test.go | 173 +++--- services/updater/verifier.go | 2 +- tools/fabric_uart_xfer_smoke.py | 359 +++++++++++++ utilities/jsonw.go | 172 +++--- utilities/logger.go | 382 ++++++------- x/xxhash/xxhash.go | 2 +- 75 files changed, 4620 insertions(+), 2012 deletions(-) create mode 100644 cmd/fabric-selftest/README.md create mode 100644 cmd/fabric-selftest/main.go create mode 100644 docs/colleague-hardware-test-plan.md create mode 100644 docs/fabric-update-gates.md create mode 100644 original.zip create mode 100644 services/fabric/buffers.go create mode 100644 services/fabric/buffers_alloc_test.go create mode 100644 services/fabric/counters.go create mode 100644 services/fabric/mcu_update_flow_test.go create mode 100644 services/fabric/selftest.go create mode 100644 services/fabric/selftest_test.go create mode 100644 services/fabric/transfer_sink.go delete mode 100644 services/fabric/transfer_sink_rp2350.go delete mode 100644 services/fabric/transfer_sink_stub.go delete mode 100644 services/hal/devices/serial_raw/builder_test.go create mode 100644 services/reactor/build_policy_apply_test.go create mode 100644 services/reactor/build_policy_apply_without_stage_test.go create mode 100644 services/reactor/build_policy_default_test.go create mode 100644 services/reactor/build_policy_flash_stage_test.go create mode 100644 services/reactor/build_policy_selftest_test.go create mode 100644 services/reactor/build_policy_uart_hwtest_test.go create mode 100644 services/reactor/children.go create mode 100644 services/reactor/fabric_link.go create mode 100644 services/reactor/fabric_selftest_disabled.go create mode 100644 services/reactor/fabric_selftest_enabled.go create mode 100644 services/reactor/fabric_stage_disabled.go create mode 100644 services/reactor/fabric_stage_flash.go create mode 100644 services/reactor/fabric_stage_hwtest.go create mode 100644 services/reactor/fabric_uart_policy_default.go create mode 100644 services/reactor/fabric_uart_policy_selftest.go delete mode 100644 services/reactor/reactor_test.go create mode 100644 services/reactor/updater_policy_apply.go create mode 100644 services/reactor/updater_policy_default.go create mode 100644 services/updater/prestage_hwtest_tinygo.go create mode 100644 services/updater/prestage_hwtest_types_tinygo.go create mode 100644 services/updater/stream_stage_actor_test.go create mode 100755 tools/fabric_uart_xfer_smoke.py diff --git a/README.md b/README.md index 5d77520..a94e2d6 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ tinygo flash -stack-size=3KB -monitor -scheduler tasks -target=pico -tags "pico_bb_proto_1" main.go ## Flashing ISOC Power Board via USB port on Pico2 -tinygo flash -stack-size=8KB -monitor -scheduler tasks -target=pico2 -tags "pico_bb_proto_1" main.go +tinygo flash -stack-size=3KB -monitor -scheduler tasks -target=pico2 -tags "pico_bb_proto_1" main.go ------------------- diff --git a/bus/bus.go b/bus/bus.go index bfbbba9..4d1c027 100644 --- a/bus/bus.go +++ b/bus/bus.go @@ -606,6 +606,16 @@ func (b *Bus) NewConnection(id string) *Connection { return &Connection{bus: b, id: id} } +// NewChildConnection creates a separate connection on the same bus. +// Services should use separate connections so subscriptions, request-reply +// counters, and Disconnect lifetimes remain locally owned. +func (c *Connection) NewChildConnection(id string) *Connection { + if c == nil || c.bus == nil { + return nil + } + return c.bus.NewConnection(id) +} + func (c *Connection) NewMessage(tp Topic, payload any, retained bool) *Message { return c.bus.NewMessage(tp, payload, retained) } diff --git a/cmd/fabric-selftest/README.md b/cmd/fabric-selftest/README.md new file mode 100644 index 0000000..6f89016 --- /dev/null +++ b/cmd/fabric-selftest/README.md @@ -0,0 +1,27 @@ +# Fabric self-test firmware + +This is a narrow board-level protocol test image. It does not start the main +appliance Reactor, HAL polling, Telemetry, or the normal UART sessions. + +It starts only: + +- an in-memory bus; +- the Updater service with the `fabric_uart_hwtest` staging backend; +- one MCU-side Fabric session; +- a tiny in-process CM5 peer cross-wired through shmring UART-shaped transports. + +It then performs `hello`, `prepare-update`, `xfer_begin`, `xfer_chunk*`, +`xfer_commit`, and waits for `xfer_done`. It does not call `commit-update` and it +does not exercise the production A/B flash writer. + +Example Pico 2 run: + +```sh +tinygo flash -stack-size=3KB -monitor -scheduler tasks \ + -target=pico2 -tags "pico_bb_proto_1 fabric_uart_hwtest fabric_uart_selftest" \ + ./cmd/fabric-selftest +``` + +If this image needs more than 3 KB stack, the active Fabric transfer hot path +itself needs further stack reduction before production transfer is enabled at the +3 KB appliance gate. diff --git a/cmd/fabric-selftest/main.go b/cmd/fabric-selftest/main.go new file mode 100644 index 0000000..b1253a5 --- /dev/null +++ b/cmd/fabric-selftest/main.go @@ -0,0 +1,97 @@ +package main + +import ( + "context" + "runtime" + "time" + + "devicecode-go/bus" + "devicecode-go/services/fabric" + "devicecode-go/services/updater" +) + +const ( + payloadSize = 1024 + chunkSize = 256 +) + +func main() { + // Give the USB/monitor path a short settle window, matching the appliance + // firmware's behaviour. This image is intentionally not the appliance: it is + // a narrow board-level Fabric protocol gate. + time.Sleep(3 * time.Second) + println("0.000 [fabric-selftest-fw] bootstrapping bus") + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + b := bus.NewBus(3, "+", "#") + mainConn := b.NewConnection("fabric-selftest-main") + updaterConn := b.NewConnection("updater") + fabricConn := b.NewConnection("fabric-selftest") + + readySub := mainConn.Subscribe(updater.TopicUpdaterFact) + defer mainConn.Unsubscribe(readySub) + + updater.GenerateBootID() + updaterSvc := updater.New(updater.Options{ + Conn: updaterConn, + Identity: updater.Identity{ + Version: "fabric-selftest", + Build: "standalone", + ImageID: "fabric-selftest-image", + }, + }) + go updaterSvc.Run(ctx) + println("0.000 [fabric-selftest-fw] updater started") + + if !waitUpdaterReady(ctx, readySub, 2*time.Second) { + println("0.000 [fabric-selftest-fw] updater not ready") + for { + time.Sleep(2 * time.Second) + } + } + + println("0.000 [fabric-selftest-fw] starting fabric transfer self-test") + res, err := fabric.RunUARTSelfTest(ctx, fabric.UARTSelfTestOptions{ + Conn: fabricConn, + StageController: updaterSvc, + PayloadSize: payloadSize, + ChunkSize: chunkSize, + Timeout: 10 * time.Second, + }) + if err != nil { + println("0.000 [fabric-selftest-fw] failed", err.Error()) + } else { + println("0.000 [fabric-selftest-fw] ok xfer=", res.XferID, "bytes=", int(res.PayloadSize), "chunk=", int(res.ChunkSize), "digest=", res.Digest) + } + + // Stop active service goroutines after the test. Keep the image alive so the + // monitor remains connected and the heap profile can be observed. + cancel() + for { + printMem() + time.Sleep(3 * time.Second) + } +} + +func waitUpdaterReady(ctx context.Context, sub *bus.Subscription, d time.Duration) bool { + ctx2, cancel := context.WithTimeout(ctx, d) + defer cancel() + for { + select { + case m := <-sub.Channel(): + if m != nil { + return true + } + case <-ctx2.Done(): + return false + } + } +} + +func printMem() { + var m runtime.MemStats + runtime.ReadMemStats(&m) + println("0.000 [fabric-selftest-fw] mem alloc:", int(m.Alloc), "heapSys:", int(m.HeapSys), "mallocs:", int(m.Mallocs), "frees:", int(m.Frees)) +} diff --git a/docs/colleague-hardware-test-plan.md b/docs/colleague-hardware-test-plan.md new file mode 100644 index 0000000..75e1565 --- /dev/null +++ b/docs/colleague-hardware-test-plan.md @@ -0,0 +1,159 @@ +# Hardware test plan: Fabric and MCU updater + +This plan is intended for testing the Pico 2 MCU firmware against the CM5-side Devicecode stack. + +The branch deliberately separates update risk into build-time gates. Do not skip directly to the commit/reboot gate unless the earlier gates have passed on the same board and wiring. + +## Board and wiring assumptions + +- Target: Pico 2. +- TinyGo scheduler: `tasks`. +- Normal telemetry/log monitor is over USB. +- MCU Fabric link is on `uart1` as `mcu-uart0`. +- CM5-side Fabric peer expects node `mcu`, peer `bigbox-cm5`, protocol `fabric-jsonl/1`. +- `uart0` remains the original local JSON telemetry stream. + +## Common pass criteria + +For each idle gate, let the firmware run for at least 60 seconds. + +A gate passes if: + +- the expected policy line appears; +- `uart1` Fabric session opens when applicable; +- no panic or stack overflow occurs; +- memory allocation returns to a stable band rather than increasing continuously; +- temperature and power events continue to be logged. + +Stop and record the full log if any of these occur: + +- `panic:`; +- `goroutine stack overflow`; +- repeated Fabric session open/close loops; +- allocation grows monotonically across several memory samples; +- no temperature or power output after boot. + +## Gate 1: normal appliance idle + +```sh +tinygo flash -stack-size=3KB -monitor -scheduler tasks \ + -target=pico2 -tags "pico_bb_proto_1" main.go +``` + +Expected policy and Fabric mode: + +```text +[updater] policy safe-defaults:apply-disabled +[uart1] fabric session opening node=mcu peer=bigbox-cm5 link=mcu-uart0 transfer=stage-disabled +[uart1] fabric session opened node=mcu peer=bigbox-cm5 link=mcu-uart0 transfer=stage-disabled +``` + +This is the product idle gate. Fabric runs, but update transfer is deliberately refused. + +## Gate 2: transfer-capable, flash-safe appliance idle + +```sh +tinygo flash -stack-size=3KB -monitor -scheduler tasks \ + -target=pico2 -tags "pico_bb_proto_1 fabric_uart_hwtest" main.go +``` + +Expected policy and Fabric mode: + +```text +[updater] policy safe-defaults:apply-disabled +[uart1] fabric session opening node=mcu peer=bigbox-cm5 link=mcu-uart0 transfer=stage-controller:hwtest +[uart1] fabric session opened node=mcu peer=bigbox-cm5 link=mcu-uart0 transfer=stage-controller:hwtest +``` + +This build exercises the updater-owned stage-controller boundary but uses the digest/count test staging backend. It must not reboot into a staged image. `fabric_apply_enabled` is intentionally ignored for hwtest/selftest builds, so this gate remains flash-safe. + +## Gate 3: standalone Fabric protocol self-test + +```sh +tinygo flash -stack-size=4KB -monitor -scheduler tasks \ + -target=pico2 -tags "pico_bb_proto_1 fabric_uart_hwtest fabric_uart_selftest" \ + ./cmd/fabric-selftest +``` + +Expected success output: + +```text +[fabric-selftest-fw] bootstrapping bus +[fabric-selftest-fw] updater started +[fabric-selftest-fw] starting fabric transfer self-test +[fabric-selftest-fw] ok xfer=selftest-xfer-1 bytes=1024 chunk=256 digest=61d42c9c +``` + +This image starts only the bus, updater hwtest staging backend, one MCU Fabric session, and a tiny in-process CM5 peer. It is the board-level protocol regression gate. It is not the product firmware image and is expected to use a 4 KB stack. + +## Gate 4: real flash staging, commit disabled + +```sh +tinygo flash -stack-size=3KB -monitor -scheduler tasks \ + -target=pico2 -tags "pico_bb_proto_1 fabric_stage_enabled" main.go +``` + +Expected policy and Fabric mode: + +```text +[updater] policy safe-defaults:apply-disabled +[uart1] fabric session opening node=mcu peer=bigbox-cm5 link=mcu-uart0 transfer=stage-controller:flash-stage +[uart1] fabric session opened node=mcu peer=bigbox-cm5 link=mcu-uart0 transfer=stage-controller:flash-stage +``` + +This build allows the CM5 peer to stream a valid signed `.dcmcu` image into the production A/B prestage path. `commit-update` remains disabled. Use this gate to test `prepare-update`, Fabric transfer, staging validation, and `xfer_done` without reboot risk. + +Suggested CM5-side checks: + +- Device sees the MCU component and updater capability. +- `prepare-update` returns an updater target of `updater/main`. +- the transfer target is `updater/main`. +- transfer completion is observed as `xfer_done`. +- `state/self/updater` reflects staged or equivalent post-stage state. +- `commit-update` is refused because apply is disabled. + +## Gate 5: real commit and reboot + +```sh +tinygo flash -stack-size=3KB -monitor -scheduler tasks \ + -target=pico2 -tags "pico_bb_proto_1 fabric_stage_enabled fabric_apply_enabled" main.go +``` + +Expected policy and Fabric mode: + +```text +[updater] policy production-applier:commit-reboots +[uart1] fabric session opening node=mcu peer=bigbox-cm5 link=mcu-uart0 transfer=stage-controller:flash-stage +[uart1] fabric session opened node=mcu peer=bigbox-cm5 link=mcu-uart0 transfer=stage-controller:flash-stage +``` + +This is the first build that can accept `commit-update` and arm reboot. Only run it after Gate 4 has passed with the same CM5 update path and a valid image. The production applier is enabled only when both `fabric_stage_enabled` and `fabric_apply_enabled` are present, and neither `fabric_uart_hwtest` nor `fabric_uart_selftest` is present. + +Expected CM5-side result after commit: + +- the CM5 update job reaches `awaiting_return` after commit; +- the MCU reboots; +- after reconnect, Device observes the expected `image_id` and a new `boot_id`; +- the CM5 Update service reconciles the job to `succeeded`. + +## Artefacts to collect + +For each gate, collect: + +- exact TinyGo command; +- full serial monitor log from boot to at least 60 seconds, or through update completion; +- CM5-side update job id, if an update was attempted; +- final `state/device/component/mcu/software`; +- final `state/device/component/mcu/update` or equivalent update state; +- whether the MCU `boot_id` changed after commit. + +## Known current baselines + +The following have already been observed on Pico 2: + +- Gate 1 passes at 3 KB with memory returning to roughly the 114-118 KB allocation band. +- Gate 2 passes at 3 KB with similar idle behaviour. +- Gate 3 passes at 4 KB after the low-stack Fabric codec changes. +- Gate 5 idle boot passes at 3 KB, before any CM5 update traffic. + +The active production update path still needs testing with the CM5 peer and a valid signed `.dcmcu` artefact. Avoid combining `fabric_uart_hwtest` with Gate 4 or Gate 5; that tag deliberately selects the digest/count staging backend instead of real flash staging. diff --git a/docs/fabric-update-gates.md b/docs/fabric-update-gates.md new file mode 100644 index 0000000..b8f3bf8 --- /dev/null +++ b/docs/fabric-update-gates.md @@ -0,0 +1,81 @@ +# Fabric and updater hardware gates + +This branch keeps the update path split into explicit build-time gates so the MCU firmware can be tested without accidentally enabling flash staging or reboot. + +## Appliance idle gate + +```sh +tinygo flash -stack-size=3KB -monitor -scheduler tasks \ + -target=pico2 -tags "pico_bb_proto_1" main.go +``` + +Expected policy: + +```text +transfer=stage-disabled +updater policy=safe-defaults:apply-disabled +``` + +This is the normal firmware boot/stability gate. Fabric runs on `uart1`, but `xfer_begin` is rejected with `stage_disabled`. + +## Transfer-capable, flash-safe gate + +```sh +tinygo flash -stack-size=3KB -monitor -scheduler tasks \ + -target=pico2 -tags "pico_bb_proto_1 fabric_uart_hwtest" main.go +``` + +Expected policy: + +```text +transfer=stage-controller:hwtest +updater policy=safe-defaults:apply-disabled +``` + +This uses the updater-owned stage controller, but the staging backend is a digest/count sink rather than the A/B flash writer. It is suitable for UART/Fabric transfer tests and cannot reboot into a staged image. Even if `fabric_apply_enabled` is accidentally combined with this gate, the updater remains on the safe `apply-disabled` policy. + +## Standalone Fabric protocol gate + +```sh +tinygo flash -stack-size=4KB -monitor -scheduler tasks \ + -target=pico2 -tags "pico_bb_proto_1 fabric_uart_hwtest fabric_uart_selftest" \ + ./cmd/fabric-selftest +``` + +This firmware starts only the bus, updater test staging backend, one MCU Fabric session and a tiny in-process CM5 peer. It exercises Fabric hello, prepare-update RPC, transfer chunks, digest checks and xfer_done without the full appliance Reactor. + +## Real flash staging gate + +```sh +tinygo flash -stack-size=3KB -monitor -scheduler tasks \ + -target=pico2 -tags "pico_bb_proto_1 fabric_stage_enabled" main.go +``` + +Expected policy: + +```text +transfer=stage-controller:flash-stage +updater policy=safe-defaults:apply-disabled +``` + +This allows Fabric to stream a signed `.dcmcu` image into the production A/B prestage path. Commit/reboot remains disabled: `commit-update` still returns `commit_failed`. Use this only with a valid signed image and a CM5 peer. + +## Real commit/reboot gate + +```sh +tinygo flash -stack-size=3KB -monitor -scheduler tasks \ + -target=pico2 -tags "pico_bb_proto_1 fabric_stage_enabled fabric_apply_enabled" main.go +``` + +Expected policy: + +```text +transfer=stage-controller:flash-stage +updater policy=production-applier:commit-reboots +``` + +This is the first build that can accept `commit-update` and call the production A/B reboot applier. It should only be used once the flash staging gate has passed. The `fabric_apply_enabled` tag is deliberately effective only with `fabric_stage_enabled` and not with `fabric_uart_hwtest` or `fabric_uart_selftest`; other combinations remain on the safe `apply-disabled` policy. + +## Detailed test plan + +For step-by-step hardware instructions, expected logs and artefacts to collect, see `docs/hardware-test-plan.md`. diff --git a/go.mod b/go.mod index bbf004f..a7a9bf4 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module devicecode-go -go 1.25.1 +go 1.25.0 require ( pico2-a-b v0.0.0 @@ -12,5 +12,3 @@ require ( require github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 // indirect replace pico2-a-b => ../pico2-a-b - -replace github.com/jangala-dev/tinygo-uartx => ./third_party/tinygo-uartx diff --git a/go.sum b/go.sum index a00618c..b2f089d 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,7 @@ github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4= github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ= +github.com/jangala-dev/tinygo-uartx v0.0.0-20251008020047-bc80b114e3cc h1:HU2VI0lw5wlu1rUgjzSuVH7IWQMNdZEbpDaoxCTVMmY= +github.com/jangala-dev/tinygo-uartx v0.0.0-20251008020047-bc80b114e3cc/go.mod h1:e3HxjGzBZBIsn/oYvWr707ug3IbkglEyivyYVxHRph4= github.com/jangala-dev/tinygo-uartx v0.0.0-20251028085354-58b6258234b3 h1:b6mCDQEeeICoGpsbKyh/kfIRnr2DMK/wACLLi0t8uoU= github.com/jangala-dev/tinygo-uartx v0.0.0-20251028085354-58b6258234b3/go.mod h1:e3HxjGzBZBIsn/oYvWr707ug3IbkglEyivyYVxHRph4= golang.org/x/exp v0.0.0-20251002181428-27f1f14c8bb9 h1:TQwNpfvNkxAVlItJf6Cr5JTsVZoC/Sj7K3OZv2Pc14A= diff --git a/main.go b/main.go index 182def0..1a77591 100644 --- a/main.go +++ b/main.go @@ -7,26 +7,14 @@ import ( "devicecode-go/bus" "devicecode-go/services/hal" "devicecode-go/services/reactor" - "devicecode-go/services/updater" "devicecode-go/types" "devicecode-go/utilities" - "pico2-a-b/abupdate" ) // HAL const halTimeout = 5 * time.Second - var halReadiness = bus.T("hal", "state") -// Firmware identity is set by host build tooling before main runs. The e2e -// harness generates a same-package init file because TinyGo's -X support is -// narrower than the standard Go linker's support. -var ( - FirmwareVersion = "0.0.0-dev" - FirmwareBuild = "local" - FirmwareImageID = "img-dev" -) - // ----------------------------------------------------------------------------- // Main // ----------------------------------------------------------------------------- @@ -36,20 +24,10 @@ func main() { time.Sleep(3 * time.Second) log.SetStart(time.Now()) - bootBuyRC := abupdate.CheckAndBuy() - if bootBuyRC != 0 { - log.Println("[main] abupdate CheckAndBuy rc =", bootBuyRC) - } - ctx := context.Background() log.Println("[main] bootstrapping bus …") - // Queue length must cover the retained replay burst when fabric - // subscribes to wildcard export patterns (hal/cap/env/#, - // hal/cap/power/#). Each capability publishes retained info + - // status + value; pico_bb_proto_1 has ~26 retained topics across - // env and power domains. 32 provides margin for growth. - b := bus.NewBus(32, "+", "#") + b := bus.NewBus(3, "+", "#") halConn := b.NewConnection("hal") uiConn := b.NewConnection("ui") @@ -64,17 +42,8 @@ func main() { } } - // boot_id: generate AFTER HAL ready and BEFORE the reactor opens - // fabric. RAM-only — never persisted. - bootID := updater.GenerateBootID() - log.Println("[main] boot_id =", bootID) - - reactor.FirmwareVersion = FirmwareVersion - reactor.FirmwareBuild = FirmwareBuild - reactor.FirmwareImageID = FirmwareImageID - // Reactor - r := reactor.NewReactorWithOptions(b, uiConn, reactor.Options{BootBuyRC: bootBuyRC}) + r := reactor.NewReactor(uiConn) r.Run(ctx) } @@ -102,4 +71,4 @@ func waitHALReady(ctx context.Context, c *bus.Connection, d time.Duration) bool } // Global logger instance -var log = utilities.Logger{LineStart: true} +var log = utilities.Logger{LineStart: true} \ No newline at end of file diff --git a/original.zip b/original.zip new file mode 100644 index 0000000000000000000000000000000000000000..7df626146aae7ba81633915a746aca2b3f2521c4 GIT binary patch literal 210046 zcmc$_W3VV)m#w*N+qTcPZQHhO+qP}nwr$%!+wAwNu8Lb-7g4`%XT-|L9~mRYT#D56M+DL_@6`&{}IvClMxV-Q&Oj=)BAt*{!em5wB(jX`lv=``iA=VGSaH|@T%eR z{y^~x8v z`!v*en#kh_Y~3Jj6RO*{?Sri8Lk|#wz~A2DZ5ud*h3Eq!F@r$%MuEW&=md3f?H%{F zUGDPj#ns*JWL{KcTwGlGemrrUWo%%$sCZbzFv1`}1b@p4s3NoL0)5gmqvUssy_SQ+ zLEL6OF6LhJ^SRj^0bt&JrE9%M{Em-9O(W1 z$kzX%=UPqd>m>F(F}-A5FR~pN#(I_d3WA%KFog~_wX?ADGutW6t`Y}#Sjk4yGwEpC{00ZInpBGH3S27bQU|u|?e=h+$v|?kUb>~_r z8911*q;fYa7@94RD28$Ul?l-QIv+ee$J9-n27lVUT@I-H2S|sPh`BH-GjJshwo05%$`E#Tv{TGMwsa~&Fy&Fa! z%6gaar&>iTz;so6<2ffYK(!DetMe$)HP@EsJq*@LA6<#+2JAC;#y*$9k%36!N@pC> z>TimJyff*9g4}sMXY(4xuF_MdJrk2H+)Ol2m-5F!5Wl}3$8Mma(!<1Eqh?C`?bP7= zqveN(_IfEgHbt;QhcTtFG+#_fU1U=P%AmuaZ7UWn3F1DtG&OJ{rp+?b z6AAQoBsOE*I|)~8Ii;}c*pwVkNienSN;|dZX1B^GxU*Rq-W0r8;he8jNLt&WvxMxp z4-!t~#WwMw>Buhp|y%2QTBL$As%9H#N=H{g0H%XG@ls=nL+?A-r?vjS5Ed zvlZ^0)(wM87a!b2tOAv}9tckbwJ+Ex|Eb~_*NWw3rW=A4Nt*7M!NJMMTkgET_`%Tj zWnDoY_PIPZ4lXXTI+@J_C2X|km9}D05l@jsp5jGEka+aUy`>o0t(|HC8-H|PGTrgE ziuR6_GCdohRuNvBS;*zT`tI&LIi1;+j6DJO!d+0QxK8mJ4 zzDX11G!8a+YtDK6Q^XwzI;spE4LyQ2>`{NdvSm!>&)1n=Z~zI%R1Y0&y*+}8cGa#b zjeeG{WUA2{E)>0%I&=KR^Uf9n>TdP*K-i*Z=n6*dx^Es*67EvoguW33+p(aUWf~?H_Ax~HK6gafcEG;t{T0@J_CE<6$C0&)hx2WNEaubblL2>!ZoJAaL z*4yH0zhSd*6RKt)ksyYH(2!>exM$*;XYJ`>`i5qES4Or!JIzP~_<5n;_0ANI4BIvh z%?iVnJ$ewWU9S34)t$E>LB}@5H%fCnoK@Q$ z=5p@4E4Ya4j#MS+d}h-^=w;K7E@QD?1wOgqe*UE2LTWYiS~rfY&`-+VO+{DKds|N+ zKMB6%j3XlfleUEI`SId(WaBDyXnZ>(s0ZAgK)LN4GeJE?5&anvwiVN6K*GF`C}xZ| z7`DgeJdmi+fq$q`J=>3}0-K8`X2a*(smk-0sEkQ8l;>N`QMw3a02%hv^DaQua!JtE zgNqEFY?$8a4MEKi-6aFYJ;ql{xAStiSr4RLCkEH-pz*CXd@SF(fz}}?J@c?IlEaZ{ zHWxw_-3*4iix2MAkxgpr z;D(SW$;ht}*Ge^=>a;NO_+Mrgw6`zX@*$pFNACT~vr*n~37z7D(hkEYWr>#6k`p z7ROd85vwnMhvy_xutzOdEawLP!h6bjoDFIAmAOl?y}dmE(XK6&{Vs60;-+iiQlTMLe-4v6dkwCcP{BpvVMlk-Tyo8i1Cg8uX=^ct~LUk>zsG6cCM z8c;jWE7Q=a6+99p^wf%NWLyAlj8t~p+34h=!$)$~DwQ0}Y}lEJOJQ9uM8W&C0nc~7 z3l(PhLg*c0QGaC7^?O>{&z@kit}cG7)W<5)BJ7>ghHyA7!1DGO?#J8`(9oU9WSsZy z#J(oyE#caeJ_!{1%%dtlX4`)9$9HRocQP)2fL=LV_9r-TzOfDZ=f^{j?yEwL2TFH1 zTTEvJX=J!Z^WlWh)H{aJ@iZh?5_620`1t#bqHMU^x}O8RfYNjWq!ovrn6sSsDCFoo zyCc+EHYghQ693mG3_@&tMuq9>_IEHVH9>*dTfvRKlZY;V+hFWUJ_I*?DllU4OzSat zDH>FAJakDvhWQ!AgKL4~cnS&R#|B7Fc8#h42TlXv@Za@0t*#u0_T91%eTUohX>>XrS4cmy12QiP7cE z=fko_9WEGnn20To5#A+$qZsZiUF~{c?&w*^u;KggbI!3Cei>y75&QrZU-qYP z0(|lKT24|ur^Nks1sbQdJY{pZP7;bvd98=BVk!?QX{27D7$23S>FykaI+;4P!8|(<*Rv& zgYS9Uw%435DttTq8pBl1yJs{gS1^(phV)NR++U-4%QCyNK44C=Qjc}$cKKVVb2r15 zW8{+uLXA(ebgDc%hTo1Sg};BGf({g3qmQK2bP?@`2~)%UU~wE}Y!8{BE`3q7{d)3W zffGMGYV097lELZ@-j9hWAG1ww$41LWR&{!0V$%+Af_9#TSb747p8`0?-bE|Fxdb7x zmjN4|tN%!_l%n-QWo?hO)^y*U}7x#t;<5ZN$mRLBM+HXxr9oYqASMS}{h5`6KVFD?Zgo)1DmvSaS;(A1Y`9U`X4{J_G(nlT?g=P*AnvR<`v4qj-ml}+Pe*W8IFb;L<6_~)WUy_3U?vfU9{=;kzj z?=2SuhyJ=j+X%z{Ds`NyWxVZ`FnAc@a5khL%8QAcK~N89xNnz#a=)!JZfbL7@IR?e#eYdqZw_YmhJ=OI(Bcc zyf1-jbck#@OVX=-ER_q?fpTx%Q0yJIDNhgSc#oe^P#m?pdM<~a3*^ccEaz^Q z8bNkbm(iuGeDM>GaYYpkNY9Z$7r{rKtV_SRa`Og7CNDdW%iA1^OT=6QrH1bG-Xl)K z|8W#b6Eh`a*CjV)Iff;#a+L#q;og5e_YUUlSC7V(g1pQvVov5EovtXq?RFfo(zu|OiCRcvJKD!QXrxM3Q zt&y9sc@;62zf<&RnHMq_&h~w&$L+i^y~Z_;SE&GshotQ<_L;gbI+ePzo~m~!dRx5$ z{&{I?4GN7e>MoP)X5!lzZnt|%zlF0xD4XtAEG@;w>zZj705a596k}eFNC%$gY~B`A zXjB7-`AL~Bp6#Ux?@0f#(ZlDXjNs#mRQ<`XL`yX%G0LYq`x4E81q}b5WQsrdjDn1c zxOjFSM8IdBi@~%IBr#K|FizREIjUmypX;Ysn$| z5j3dhh+UStkl31a)@!rcZ2QRO^OE^MAFN6s4_>^II=vT#$DxAK_9GhH!IvC}G&`H6 z>bH1nankPa#uHZu7JC7^qFdZU8DGf;^M3Fiop|bdBgClKr!JpjG7cwlN^KvMJQgh$ z)bW&%Z=N3%1Z>=Cm}{DI<|j^~6PIsS;DGh~wvCeFOP<{p*WMdIMb*P4c_vg;(EP(s zUG4sbj2Xx0U25x7(OZ?+4W8&FDfQjFWx2GLPaXK2OKdKkdGM%oZeZ@8HjKNO?`@Ox zqQool=u5x4x?uA6?uRyobVx?_dCm!b0g50aL$4aLsPcT2b_~QKotTy6>c`@A#q-L^ zOb~G+t66*2LfKZAY$WYWs?edTpb@99KD3A}uN&m&n&4_J!S}lY*B`I`mn||DRhpXD zy(->N1)FcQf!M7|V+`qFDVLJpn+m1v0K{#E^1PNnoaTb%0{z8MH^+Q0;b^N;h6N;b z2F(j+A^q@l?;6~Jj?*q70zrIJm}_&Duw$^e6ew@TozyNK<3`1dVqAs2n#})a3bJDU zNwM70XHLK?nJT6jWMj5h`+I(OO9n14_^28zq+KS#$Le`K!-1{xJlJ}3A(t{ekv9cJ zbeg+(CFMKQ-DPQ=Vfn-&mG?}=Q8w+OtP?M*bnz)iTV^pIQ7blUo3?T_*L6s(=BT<= zwJ^@f4s;f;JZykZJ5t#a7Sk!dO{R4Z%kX09duB7Qlyog!plAGyD+n!lxjt?v8LviP z{K@N!26aVWZZ34@>H|h%PlL!5a^!+(byJEZjOMpHCkxUhEf9V58TxSQ5R`!P)@UPk z2}+-ccI{ajKI6^3gWcq)RHb0PUURJo9GI1_gK#sC6>W%jYv>iH=^D6Dz$m8~!EPCR znXKV9)0Uk&hUFw*bU&|X)B>0fJ2n4;VP<%M^>>F<2lq%!MVAF zglN>fyX<+>y&<>hyJB$k5nio8{ewU~`nLGnD|drKw3E_h2)F8(RwLlC51 zWY9oa{Wfz&+zRn8Yy`t3h`W&a;ILc`@vIMoMU)~?D`5|#>(H+HJ@DUaa=|_~gq;=j zFN~e166{8|necdVh3QwpRbQd9(Q6>q_~WY~|9_t2goi%KKIYm7MY^yK3>|C6t$ozb zO$AJWRG5rUR>=&;N4l}lI_}{xh7_c{7%mtT$^>&b!y^k1(N>jdXGYZzR1g*c$whve ziRLbwsswUC2?OO|7RiE^RUvAJPtlld>m(Um09p@KaJatrghWIt02(do_rftL8%KXC zne<_)mS;v%$4iSP%Djhb!*#9>K%MgGV?}yesNRnE7w|kuhe7EP5d1l5HRRBRC7C!-pF8&A3+BeJD8+n zpmpQ$-pbQCRzDa;Aq4YctXli_`PoM6X*&Oga?Z=Jr>7mZRslLb4)GUVndSl<#ZM5= zmfx({Oa;tsL|`pfe)Cd5&!7|moW^2qAG&%k-!(lvF2?A{`~?+PVSpU~e@>TErX!Xz zRz%0j!HeX|Nk7fO4^NvxC2%(KG~7~N4)Ma407M`gB^O#n{R5V!Q1C&H9rB7U?rf9t zTAp4B>ISJI2)_rD{{(w%QVVl?7zC4dtuM~h(_YH9w%ZalLJdxAqFRjZ;+dlil4K&C z6)~E3ol4K>IXEBZT*^14$!>xsiCN=+QRH#HK^iUGMUW>Z0k40q9T2P=9}Nx)2eRN5 zOWO+qT^TRnBqThOH?a}_q^8tLDxI}3x8DQS8n<{0${_2N_QL!t7M%$f3#wQh7?H)r z=|~2bC7;O}m>gK5G?gg&T?x=A&um-#w{Q=fDdM5$fxgrQr;qfsLoE_RW=<(H66#p=|=F?W4;lc4X7GurQH@Ase`L|7N@T^dZiK~a1P9qnW5m& zx;sr%eND_@Eg^Y0Msjj8N_U%C8R7NqX)DJ0UT5-t`@DPS6<9W*z~9HvsrxcB9FWL9 zZ-I@Sos5f>ZuaHP5>1P5 ztR?xCP_E9$47^Z6BOzKVON&dyfy=Nk>%#W$7}xW!FE6}SJ+iYCn9+?V#9p9)Rz7d? zZ_iiy4G(J>-&(x-NRdR;W-n=#pb#F%=@hBSZJ2 zHrUREGP6P+g}IFguRL#jzuTG-fld3=0KnO5+X@*e>KZAiHgPbY!pT>hi*J>wqZ$lD2a(5r_f9L(R*BBMR0MIYinyomS;*z9KGLet8^fA z%AfC0`}I-sU2v!x0w{=(5)JoFHz_7U^4eF-b)}`E!U4c`58DzG?YCnLZbAztCnKS} zQK7EMR2U(k7#K6MEB?7lP|OWqHIY7>q1ka49b?dNQ~N2a9`pdX!h^Ur1RsH9@k=ZBxt-*^lG!`6Q)#sxk`DQAhyu zrR;={iG&wk(hAH*J1w_Qj}N@7+e=NQOIC%^nyii&f>Tmbm^sWHym1ICu|oqRL7c^r zwe-%n7)}7YVA)_lTjfU6Azvr3+hNA^NMOn>`(CeN53t=19ni@PAFeA-M!wn@MO6DQ zF)?t0wa~o<0n_)h=_p;EK%TsNviFv10+Rc^DK^j)iw%fw^-le3%=x~(kDWrQL zeiQ$0$xl1Q*;}z^<+?%SCpP`ny@cTbSO-n`MCpH$*p;206}`J&PfkuDbPR0=>5!9^ zS%?ge_gSjdyj!hZG+S=t{6@!qUc2bb2{>HVBXb1=VysyD=HbA^!;fR(-Db5&2wc;b z>((ELvdL?8v)^y2d#xOe$Ky)=rs9Ti;R6-S%WU<#+fVoQzNq(NDObt9VjE8*l4yr{6ZV$Vi;+VPh%}H zAU89!^U1zwmWEEYLOl~ndG4L7f*S@J2#!nJoO922#zgNz6~>;N!4cr}%t?k_cP7`@ z>V=drVQ7rmAg$Jl;eGoNyK6walYDp9ElKuXnA2u|Vh84=q`H*>#*#Z`d zr)*9R4hUDmq4}?}ve1%}k{00qhL^LGm*p298wYPEi1X9VSsNW4j{bfHpnGREYCUcE z1Lz(Ih`Fv`&U_6DI!Z!yUu&ysevDLNla^1_8uS#>^o3J{%0&;vf}n;S4wEHj1Qb}! zluT9AVa*CeLyDu~v9QolaFU5m?*3%SLJ$KFZ3((y2?*du^?w}ZP4jp0=M-;me?Q<) zO9SyU4LcwP?qp=+gAcq4|87YzM7Sd(A+7cr8JiefL=KaU5tHuZIGUOo8rBPq6VHGz zi%=&0b&g3)WaQ-ZbQuObj2seg zZSd$-fdG*rRuQZ#IaJi~Bv+f+ULvN{W0vU&7OinOM!(-k!*j z#=y~2)R+Z3hr>M%ok*D*-M2riz3$Pz{f!;IuX2fVE$ihqU@Z6rp68ZhXKB|*)HMq< z^lpK_^xrHN4u?BpA$YmD!AK})0<_}W=Smag=HCQ^GEl{_lU9yc_e^o7i!;oU-}JkQ zj11c*&ZH8wxyL%a9SFrA`mC6kn4A%#*g`w~d;@cKZPo_A3TO%p2>zSPqi0DzeNE~2 z@zmKtwLBb>N54ByfX7bW)y+*RGk(B!ku0$9i3*-o95-Lc%|Op6#@eUnz-6K?;?aOKae07{jco z?P6cAt1kmoIQd}uDpf|3^_CS?L5hCQI8kS2ri^oSW-eLwOZShZ+Ia~6{BlUWKaCq`p^2Tg@l0w?OH0e< zyr0)20Hq-m_Qm-j9SR3)aYqdNNXcu0f(L(plFZD^YHJiIvJSyom3+sE^ExA;TyKk? zuMN=piFC!H`k-LeBLc#*M1SM4p@jOMMo0Hb85I2p0so|DFE6%Zr;RJKfu8@`^L}_V z9{TiGT`{%#g*8iyHC_=&X+|$%siz-@ngn8sJ62GHWIh{&AZzhq5pHqpO=Vy(q;j$j$6&sn=Vtkuyw{JW z=p!?6!bBPem}h{;U80FBA~5jK(aV%_pJ{%avJ8Ps&eqbh)5&{V@A3At?Y&xX1`7FB ztr3YY+VTU?V0C`MKvlUj!ZU4Bd3~e9u;)~4j^VI6z6Asx$a~YwCDjjKnWWxh*wiJM z!a*KTT=-#ca<|;l0uc!FJ2K@%B+S2o5zaot=>o5WJm+np|vZ77u@)U+w2|rK+v7v(7&nYupi#e_@t%Tne5< zIye0%eA_I#CFkw&&T71rB|!OguVDHiC1sc$skX3`ERm$!3AFykf0U%V355=fH2&Kf zYhbcmZ@|YzJKVHt0ryN1YxbZb(kynS6faod)K!yETaYwsg_u?VZWJ2OPe%|xiwkp% ztLV%O09$PzTiB{6F;Z+5rpLIkLzARIzXe!xvgm?%t(NCaLscss#bN4UK!tzfv|<$% za?kXovQOdC^1%mf&0k|;H3A^R569=pLakQDi{2H4YaOmEkg8(K{|YzP10wCnE~q;b zunVbb@WGmyh#?E~#;p;mC+xw;TLa5k|x0C;5orx+CW zf6i|GdlHOJ@4uCZ`oEpx`d4I4uZsyu*wMmSJQYg3(t-Dq2x5)$g^2V|H5Ut~nIy^?9>-dDYiH{w@5mselxjWZ+c~xu zxtct*KOM`!4`X^70K}GF0sQJ(5WJWbXH~-6en{wloiSyWH7U(lvu*~;Fn=i5x-Z|y z{fSd5G!~9qP;bt>F-0Cmk!eq~K*^pk#)w(}Gjwsk+<5m(ND{<%?@K~5gDsonuM;q+ zR9m?3$oa;J>z>)uz{;s+^RjFWv%c`B3jw}N>7?hc#?={`ceiCr7VLc9{(e62kUlG7 zK&vFPbkv@2G6+sl(VAmrQzt8D>i^HgT%p+$0vo-~2Zbc4|ysZe1R6p}^ z0}`4FI2y9pAJWj^Wy>S{#EvlBR`(kO_6C4BCJEb(}Y>WvB8)U zC$L=e#%=>sup}1j&U?BEBeo~)LwcYkV&n3(L7l{Hi=67qotCcx@xIkGZ6v(ipTL}? z0w#=`=IQ>LFF^&FK$EL?f;fKhzVPe#drrxb>~G zuOO&C5eJNW8s)h<9|1YD<5TMDKWM*5uLu<_I5$6vWEY{+OQd8e=F5k)WklwHK3J7) zs^@0J%XP?DD8Y2cnkoiS=UB(l_{py_0)+fQa}I2B7VHc14^Bk9@H{t6D=b7eD+#(c z;(iv#f~OsTqV&gT0Qe6^rT{OiK-ZWFSVlXUuit#Yvz)bh{VhokyYT**Y6>6TSXbxo zm_CmYRa=~?J&erNhz&*LWmn2DP@b~9ypQ~9lC~s_C7-vJC}_v|DHs)z3oSul>x=dr zbfzg!Zkw?#77?%*#$fo;pT8b1$ zzw9p?TnLAf5z@b@<1{E45PY(w@|@;Wg5swiU6l5@(Pq<@uQS&2$Ddvb8wN zCuNq>vQcv%R{=V{R0owp%5kHB6=MfYyI?S=2;*;>qIn~MW^)-+ZHDJL2p2ELOl6JB zZA8?MHrP@UDCn4-3ArmjuNvrOLIt={!@z&2YP+sq_ZA-3!uBdwSpg~*TT$&A=2IoO zjp`&$S&q~hn*ej!CXPxEjklTzNL-p` zE_KT#-iXMQ?C1P`-d@oYMw`;CQL8B%os9atgHFxU0L%;>Ro7NbMM>uqK~y?L6-(yp z5=btJbx}kJSu4ErYt|GquPO%166R&k)Rs0mpF!z3SO_p83&znH`$#JZL^$Vf#TlPs z#H2>Q!FHPFj&m-&YxWC2ed2_cH%=obQt)J~e12N&-2!#4wwt1+boL=m;y0rc^xDtG z@<6#Eg{`~Xx|=V(qn>V3E=+h{Zm7{~VWJfi8`i*PyGR-x!|ro$2I?i8 zl8^dJogIJN9Be({RhK!AA|Tbe%9+e)qXtSM6UI}TIhyvdoRsGMZz7;r zLgg}m7uK5rMWj^0Hvr#(yn`~xg8b}bW;>g@`qV{2L>Jq)?V}9O+IU77sa%X3FPFXc#$8)ghMW&fDpjHRW z3Xo-heOT=3&fcc7HYg7cS=>xU^KB3w#CdqyAg#IOPUmXAa_2^_B6_W5spSB>pjX@S zoM^2!2&r^h(PlMEtveB{8B6d;g-9h-tyLX|TXg|c((OI7Ru=PQnU}d}d#P;wEweuOPq>q%6Ass*v3Hk_vOw3T0l8R6hFvJ6E;h1-%B>-61oG)sC=sM z<^rQ$(tG?Xr;fRlTWEWcx@#$wF8@A5G<6mFPV6H!hL~lPx`?=o6?%{+afjM8^^*Am z+6Q#n^gs#|^O@(Qk9#XPdXbDYjbdM(A-0q@HDZX-!2A>Ae*e`dh~lwEbB~f1m{EZq z?bjJ&_lqeYkP@oyILF_f++Md<^}F|B7#mn0k5WOTP1=Z*4P6{DV|q7 zPZpSRgxfx>2hwbd8CjBXo4m@#F#g)wYp&Pr1E^LD?^G{?bcV)Jb^LDAMYDyP=KIZH z8#R5lri3PIPU9r!%VJ{=lonbmQeR6emkDciB6qXwhe@i8!Tu_%r28lj^h*wNG}qXx zG2!cgK~LXs0AL=&_)IRO7TfkiO6qm&mFkBf-i`NjZdAV|-tbGJhxM{!=}FEq4LyDe z=u54}&Sie+0(A6kvM%)PDmz0)M`{2yaRb`x5jG~z!giRizs4Jc-T}+s0ZtmyC98^+ zoMOEJMq)Q>i0~_(?$o}ao%=aGKdy~)9vHh4+hQYFcmd8N)t>!B+{36L1d`Seo0zG1 zbn2~47C3*@b&t{2S=L;Qo=}Cy$-^%wf!o-w#!+#^WEN*|_ZEj%8_&K>cElx;4l(tX z<5HVb4c9J}+yy^mWA38BzAOf2e`(Pud!?nC z0)pl?mSA+V+S6R|1~cV?Rl^65BobJmz|E!dgIm!)7i|<^?l#K>K+9fc+pG--!4bAHn)Q%-I( zeq~f=suif?qL3}Kx^r)TX*$OM+H zG!Vr^nK-Hi`pJ%)X-C4&2^5OE zE%p&1639_UdhcqP?HR!5PM=z z%)37+q(z}~RZ}F1KEH-93*;FNVKW62jI{+qw6zYmeSNX&QSD7Mcy}Y@LfG^av%x%( zu|J+qzpG2cf^cwrp@tQ*C-P{?is4^4b<_J6_U#E-3k&?%pcdtjA+A)elnY{I%5uT- z8G0_h#AuTr;6aYCX^%T-7nl+BiGCcIZHfL7x&tU%MY1V4L;x5fF1xyY5J(p1;0b!$o{SHaP9nRAoJt)@l6ic&yd?%3+c5%coOQIx` zwRkD5vATUhd1G6aNWv%0@Xid4>4ZsfgRK#% z^x%QO?L!sIcY;sET~}*TF^uS4@mJZ$6Du}cJV|l7M80!kidun%h+cs4J|h`BLA8q0 zBGH2|WOrH7x>B`xs#5V@I|7Xti4@6!mQ4W%0facDM_7>flSoaFo|Gt6CIdyhk?Jo> zFH56sabk(Avu+BJbTHMUa-=1omI4SGEpcZ%$y2Boe9M^d-jML4boRJ(K?Lc3ZvcF20D0WgH(J93P&a{k^C{)v%s50yaGJL}G?22b zQWVf`e;>CYSm5LxFrNfHpmr9DDuTsmYoJshzTOH(#wgYmiZJI4bPeEkRS6#fb^1JK z|AF8?1NeS`KslKj70?V+ih*D80JEG6Qi!P@gpVNr<3{~0lLNd&$pD?1CR0a-D?p&m zQ*T>nU##iODkUOui1|3GESt#rNR^uA`^CO+mWzickef9?Xd^00H4BGHpw_5%!l~3b zX%&bCK#CLpbRMlLYPN{}yqUWsr*(j>8XuHk%7Nbvi3@EzGYGMz!m8O{ZV&44Y2d2f%Zk0o}c#3g^KBBmhd2zp%4HgH@9qH zI$2T*cnPB?`+4-S1X7kqXf6+OYD(%1XJ|+MOcVfxLeh$J4>TVjZ+Y~iz>g6%S z2~ejgGfY4UNf+uD%+l%qA>~3M0)ZhOeMGuGzOVNhV*Pw{hDm)h9Hn3+2^H2gMG`_E z6%n&dTyXk(-E?Io>O&HjVxR?X0jp%ux52MnotaMea)H&8t`W9!0eXao&;QB%lc%lV4<#9% zrhmDjX)4zhc+~H{nS;31fgD-LKM?p&=IcD-M4GS%MJ2~ylw&Is!L64Vvq)d`1lB%5 zY0nj)crCPzZH4cmXY8nZI*Dah071V zK`=soRIQ=h#A!P}genGADM6|_>P9M*Hk~J|+|@)$L{Z$fHOB0ZJ%J zahG2mTw1?G`=1|DgjsujpK*f(8~i7u9rTo|6QU8+;ujGsu#>PpUerZ9T9G^?`Ianm zh%Kl|lmcak!{bz(Sdr7$?5L2)t<(e~fQx_PKV{~CJV#&QHT(l8q>-*FJJ9yurgFc5 zfl`EM!+=k<-bbyY|~2w^YlFxRI}5lju9ow){2K}FQG6j%RX%Ny=^^Nih!0ES!K>BrpE- zP(giDN6-2@3DmXdN1BTjYI|&%!>8qCp$Z7 z{MMzzt_w(ALIW@rb%dwI3%Bn{9*8eBGUo&-{vq8j%Pw{uR6bsgA?r>&g(HRDV9SuM zuaVTwOlbQU{MD@f{`7W zVluS>^*C61=-Sgi{P(JESG3Aq{hGmjbl(j`)v^lBa$7_T1v}uzv3DJUx;Ksdc-o2> z&jsP#)Cqz{AqGe>9nwmDJtLn&e}pkoiX<_^PgGs|q0q?1(i1*5$%)fBc#v`SJTmJO z*fy(ZrWQ08T~&{=pREJm6y+zjFTG z)V7qBSSUG@2cx?cT^t}BRbdomSzBgmP8e&9k$v8&Fra*m)K(l{{-kU(7YQ>_W4u~g zfll2T-*qDReHZVYxhhsi3@RgctaGC~bzPf<@IEMip&X>o>P)0I^*~a`0~O;VtMhOl z8l`&@HI;*c7@No8@H${F1Wv^Osvik=v!^wc1}}L5u z`cFw3SW3BlJ8Z7{@fuR^09L;~^=ZUQ^;9_U#imn3bOJFqh+URhyu=#Yge4{pErk%9*pX>|HP1iyPed;|*y@?FSG;GE*cLbP&H&&#v zL(FDe_lp0Axp$1Rtjp4c!?tbPwlZu-WZ1TCX4tls!3^7WhRqDyH?peVzSZ@;Rb4&q z82#0cefAmW$1~QNd!D(Ui8ZGkP|na$aiZmACqMzKLHn>m>5@YrbxSmQ<|AhN9H78` zgI@7-XsAfaqQN9@Nr~6MOqCYNI=7B1s9()&lz}rxE`Jg9Fa7>|(2h-is6t~c>S@}R zHaP~BkTb0b0mloSj(W4BRO?4&H^E`0?#ZZZik&Z z(ii%P?XS(PzBRaLsaY{LDqlW4E0#pn`a84C#Uu^Krr5wga?(2KL0eJWjkxeGJio>^ zE;e${K681C#IlAJ$mX6Cz`L7(V_6Tit$^?ZZi@0cqRZ4+PuTAC&|Kz^r-(8pf)1f# zgAfpwyBME?VYkD<-Z0S`K#j%qRnnLkicVr!aUT3C!QTh#mmy&-!Gv|zA#WfKSb`6^ z98$tHk8zQj)|}h5Hd&*=Ylvvu+THVsS&4>u?DvGe*tna{HtE3ywRJevRlZ{FUW737 zY{@Wf5b9ckAd|U~WDoF7#rhEM8W2G5*@Sdr=Lzuf5BO@gFzr1(s7h@pJn?OTVG1U1 zxJ&5fFlGIOR>t;FDbAGnajJfNe)HA*px8W$|KLo2#rY@8Xy3Q(?4Hw(qSHnN6jPrz z%jzc2(E@-Ha3!IrcVy;X&f7d&))-%<=nVrOJPwR`23L-OZ^Z%qGSo_~|mI( zzKE2ty4xK~9wmX_nC}i7U`{k0sNLO~mC8FJM`k8-ep_b#!h=p+nj3=I=i6xj{?_5Qk%eKU08b1&k0=qy?}0h& z25`Xjuz?bQ@XQPmI9B0TDM3IHLvWPA?{~Dmu)X?*x_KK|^2tWnztTjKevBL28sBzn z!aNC9E+a8dg0I&wn7LW`gbMaSpj0(O9n8P!oNgT!a|&D(>*4vP8E@BzQDvbIv81=h zYnxzEOYPF#KPFCdy-nXK@L9?}D8*YsAmax;sUciZzvDE)=%Ys;;Y(a1EM4!1kILN7 zx*?!VTZ0+uwpO{@kL&13mM(~Y{WcZCAo_8cyJUT}cAu*o6R|MgQjN)Q3V(H4o~XYk zQw2%i`BZM+`|drR+>ZBB0I|v7#A<-9Q?)=jy$+LZ*<*94+>#-fa@AA=dcpAehW^{T zo?uhnm4pKTxMTWT@A~gORMh{kcl{Y{|8Jbt|83X$ce#nmpOl*zS{waouOy()rbf`8 z*YErPEjRglgug2{`2_p3DE%K;|9|ZH??WNKSNwa=<~B~orVi##?ti-bKe|NyzW?9d z|2@`!39I}D{VX;51NP74@CWL@lN_A&9h_)?*WLU>Io$n6yQu$>9R3sbAIaf2>c4{= zkp4p-?92^qY0dPlXsz|lZT_JwzI;|6{d1kb`CDV~SDpAT(Emstzmfm92b2T1>0ApgLa{h`pm?+Y4lHfw{3A6gG6*k}~u$KQzdW7yDt1{-Nb>krA2g2LrO zfr*|Uv+czFI=^-*C(8A`1AIpCuH}(1HA%@Cs-Ha5W!-vA=H`CMxJZ*h9AKYeHH{QE zDu`1@`7u9^d&Xg(ZQeNx_o^-8(MexG(-VvaeIB*-r8kA>z&`tO#JST~h064-U@rvjg1rg)PCX)|2!d7SO60o&20;XQ}J=_ctYh ztB^UO#w#W#uVp|ldZKcI-7|p;Mmm?jqNEUuY%0~pJ^VgKU%5?+^x8^g9%HZ6BYCjv zNxn>B*3)QSY92c{lf6$0KHbs_-C~#vJ%xN=szH784T5%OGSME@j!(XS_^IJuWI$QX zF~>RQIQbZFisd7kJ`k4}K>b_^1HZ*L_BI2jn_aLovQixg$8I8vgDM+1qu}(zaJo00 zgS$bno$n9?BB^kc+DHwmYjC$0wHDZ7ABo*(RJjARs~aEjJv?@;d-7fNI$rLYri1wF zD9_zbN_ss?l$RpqbZ@T*+yov;4Wwg;Ff@WyMG-M01gb@*H;=ks#@;=dqnjCe_H-<2 z&++RKET9Ac7y|p4nS~kK0FSSxOtA}tWrWWtkkPUMvfI8}&HUZGv0~a>U?KQTcW=j2 zM+kE(f|F+}9HV`y6Zy*a@CIp@a!^?4JXfC_cobMLh{-k^db3uq1~BXmH4%g@$uSg-Ma5s`O; zph%qI9pqZpLrLbkK{3u51%Nv6X8P?u)*K*$pvWtg1`LwY57KlAMg)HtI;9*a?AXE) z$fF}DDVC#%;t04u!a5?K?BO1Q3eOOTVXozOudBmB#aA-8G9*O^35qhDeo6aeu?iEW~t@9f1B~VU*&&4 z)7#STw9q)lC|_w5k=or?QvvZWz)KnRhVriqow?$MfefRRHWLX%k#S+Z0wXEHSfd5C z(K&<}ew|}thSPr{EKha<7nC1)00zmBR~5frgnZR6QmVdo8o^Z61g*xz zm}<6;`!KCuKY4hV{$8|J{_tvAFQRTt(aeeRT#_ZMwT|Lq3)R$0d9((SPF|;jvGDOs zP27EE?8lS6KNOSB3fLg&Ni*=s=b~9`S}}aq8)*ZkYt+@%+}5} z#U0yY!H!12mv7RdjGeaYSE^7GF|+fcSA)NHu1{ngmliLkm|6!+2o~iTkvfNe&NPgI z(?>VIqFQhyV?aLLd^tIiXFS5lkjG)C=ygWfW~2C4qVkG~(gP6Dom;B>fcE{eQRIv; z9X0|9RY6zsfX~|%Mur3fM(VnK+(rOpiZT02#`_#+SzRU`3(Q(qsXn2g`C_x(1uuLw z@V?3Q!yM^EAJ7X_;ql%VF1O zY~I~`XK#a`SLj+^OgWdS?~gpo$+N*}1HEw=Y~ygd&kO=M%**X*0a)0zBUm@>ThGpdYw#_;$>+so$^UHf<^P)2 zW;X%~p%T8~>B>;hL^w)UwsaJ^oWmh+v}Yrd&6cCzZ+mz5W`Y|b=m1s#t0uLm0StW3A!(;oQ}cji`qO1yMZuW?Gl=qExJB{(5=rPXiaNV8apX1DIm&-TeF zOb0gN@2OKJY^9K>bbv7mW;{bx2*n(F`+>8}537gSjS%wZfIVXo7-hQM1_9i6B3dQr z(w-F9RGG`TW_tL}iAz&XmOInlK1x6wB&^603_QO;|s#0jwulowwlXzV6%H!Fx8CwR?@=6XuIMcX2w zj5}=o@CzA^CjFIQf;}NrDjH;48)pJ0lR$ec`qQ`KrnIw2W?TBFbG5G5Hi{3UK>N7_ z(A}~`;c;d`ikfCmCSsfHoSUj52kr;awDOpqGl!rn_5wX4QoK)6jpzF6?oUu87s|G} zZT*Pyimx7X)h^za2GL;gi`<-*n&E?}ZN31`p?OOa98i*aN*p&@OE)p`{h&0DucORw zDT+t*g@rz{`AeN4ON^oLYXe&2K3S)EG)|fjL6&Xc7(^8Uq2F6hoFLYLv{sA6xG-K2 z@H4Z~GtLjBFissBPMUyVd%2JoGTB);fERol^InN_3(|_4NHN3%MXcUS{CyHgfyGwm z&tb+QrQhZDcr%snaarXm(1!VV=lr@>epOtK9O~cV|8_246?;KxAprmsvH#Y&{CgjR z;P3h5|0QO0y8i+-`*+}!n19N#|C`1CQPwq~{#ooljQC;2J0KIjqyN@PS2V70DUof>nQ$Mrl2bzv$0j~@)t*tY5jCL-MVe&lN*Lq#aesvGjfQ z?0cd78ESq!XGi%vPnhbDXSxK~NShbQ!(~tVRU2o`sxhLp2pV`~?_`th+cx>B4w{sd z?J7U*RBh*AwB>bEmoHux*0l|&CoM}&X1h;J6QUF@4+C4QJ`X|nF~&R2m)EbHwCfP` zg1*FHuRF>2=EsA)?-d#Ha)$D=kT83{m0wcjAvDDCiA1@3vYCwoS1g&lbpJ%>eKwA?IWQ+Nr*wSY832$N z2{f1R7$w<-){-}gMNy+~5_^r41&(QSDc62EV-09fp*#dqdh%8#sn?#o)#knQ@p&;| zpjZzb3)%8wvn?$*#)nPItaLXIlY_0BnbLKI1*>i4HPZ*CaTFMT+1%o?*rAa=@01AU z-62Z0Pv3-00!d3RX+_IK#`OI4PBbi9xdw%QTL;%*Va+jG24nK=gf~*fT5Wmd9naIt z%JrBrEZ?bh^*kaV#t)tP!%rOdVA41?I$mhRIGp%Pb>X0^g$kGmG2#syJc%sKjJWoJ zBHz|Wuy}u!MG(Pk5P&@~w{1TO>~-37nY^N<(fSJm->(Qt7Tt>KSdWuFceqiGc&3}C zfntUlfOaFW1LaLc4fTdQc)%ew_Xr`8kn$Env*lOfdnwjyEF;X5csn_-_p6-5J4i}G z94~TPIOzQ|C(##99E%VC7Jx1N^vkrYSdfn4GB_nOGP@9Q#Ofr9UY7(~N$?+J%(*@M zv`D6-Dkl_pnWs=~oMjZdE>3pR3S$uxDCL~8s-=AdNI&M~X-x~Y#)^imJ8)W-vw{#+ ze*%1;)p?R@|2PLpw?{ASq$0CO55;uQ44`m91IiVB}q=?*DB&P8M4ZrA`N3uJ1lAzLUc;rWI^3LovtYv z@Gy!?Ne;t+ZX2==dGMmS1L6qgSg!PS;(&rZign2~OISVqVw$kT>fmh+PBXzu(F&TN zA{k{ov_Jv-5lbS7EFkDyc!)WE+r9PP#ORwpHHwnMyQdOJwzue2mUz*0f?K1Vv$cp| z&uWR`Yy&me!&iGukqCrqeZr`i$&l5FB6)fM+FdJa#i{N@_uM!IE>TgO$;DATMD}@C zqwwP3LqX-?M#{JmAbuNHSz)^Hx?JN-jm1X{+9G{e3#@n}1I9d&Z5Sa@T{WM33BXxi z!@)H!9dARJ8=It(|flR;HxCN;3`0qZKG{i2C&4UP_%EXc|eQS)?2u~WY}@Mz@{ zu_$gvAkf60sPB4S!G5z&Ow)WjK%1DaSERT*EdZ==rAxpUM6?Zl z0+xZ$kH9(1S?JdfiN^Cg$Rcdn1W!Hsg&-K$3JEB36mzOpAbh6i@^4xg9j=E)q> ztBO59i=oYuq3c${s>GQI8xjPR^>LQ5_`HY%*~|?auEK_Tmoo45GAw6e_2EFW*e((E zW`(t;p}Bv1OUyMlVR>JDyy5r3ir(;kKR%q*+c5~{Ss z3kwG$wkQRY7!WGzu7=`^$HS%Qv=tPYYAMpmke$H;E|1==-=5qWkdm6(R}O*JzG&jg z#&#|@Jne@u;}IQ(SO>>jhe1)Yy7xeHEPuQ_3iNTCK|N+2>3@IOqLU0vvy~Hs?Sma{ zCzHs&U@JE-dE;HZt;2--c&pjwyB-Tnk-HYg>tZ_(?%!8Z8)SSTRndtXUWJ(?gh{JQ ztZ9;6HS;>E1D@L4`Q8?h{#GT!DOF5o_%=6JFuFfSoUVy?dhtRM$DU7| zzNFNkEo0oX*r*7`5#hY7g4a%PK#_qb^r56l3m1`797}N_^AG+l@6t6^|AYgwj{$0I zQSjqIeCj&enjlnPsrT;XZ~U@7oo)vBT0Gyh)Q-#TvY_wGawccLT8gW+(>ip{$~`5*kw>#t_EW?@JZj zh{RSw-l#4&LGcMX;YYSvV7<6wIDNF86IlU{P$(WoexMv2U+K`S=uUULti?C57=LCD zr%EW(;YuFDO{D@!kBAQb8%Xr;bTH|$Wha>j_IGO7w*kQm#V(7wwcMFkk z>83ivC#*Ezn+B2i!yMPDUsxf5qc7~4Zv;Fx%EE^Z=Udst$Gj*CO6_~#4dz>bNu$wm zXlOaMpdAhCrCC;@(`o8P6I0HpiZV69{Idmf%@K~U+_bi5HrZ1r-rwuD=q5>`zYP^r zSD8+U6eYk*m!6~STg<5#$|Zs=@JYto7Au&mE%%+r1;|F5ynbI2a&N1xXk1&ZzNbF$ zxwTq^RzeY4iT2HIlF{jB+WX26$Ux$345nzk$UpfAE z-K07U>jIBbZ_?PrEaDd^fsx0_{HX)Nu0hkBUq9Go5ss_STb;$9hV5B8u*e1!}v(5h!yZnrA{|lt;f17ds&TRZWYXC4qYsIwoQ5G26Ufj0*$7{-u9WAVqGW^L71XNv!Jc)h{ z1l!eS1&fyXB`maB9s)Q_jvmSx?o1$jC4B`L%td*JFr8lryF)9Jbu?T`9wFf*H7Ks$ z`}KqwO_Jz41lTED{Ok*=d%l27GZIyQT zFsX^_+tK+eU@2}UR354u784}H1S^SQ39$xWUy{?Jzc4sZX1?d%m-eq944OauikbCa)~G%Pg!MP-?D-;GNn**L3Yf6~ zl=%ii5S&*(8^wPO^Hn73PLT>iknv<~K5Y!S`wP>QkzU4459)OU-r@EPvgpfk!NV3g ziJjR?$=H`FSwT=u1=25q1LLR0FVI@A@V&INd9;{Sd8$}k)d+e2SXa@@!ePXFnDR|C z^-2i2T7W;)35%c(mmm66Dbw_3K?|#jQz&Q}coA=~FPR1&LnAkgXlSB2bz&oY9CZn) ze2XR##=|GGjAHTaifb;+IaeC&xR?X;pgWlDNQkV#$W!d`syn+32+TZWnU}la&Os}c$zE9EtIc%BAq2W;L|BTHjGWqAT-%T zck#zNlzZ1cRZ4a8FPY{<2U8{TPklQ5f6}Rc;ko>8>(rlFXNb?ghLMB0i?PF>w>tR4 zBK^Mq<3HH%%-!GG=f48}J6Y&I+xzeQ^B;THH*;cQ_=j((|6$(s575tvMZaPHksSVA z@~*$%u751q%E^$0fsyt9K@$JvsN)~7bh`fm{O=@-KYMGV{f#XCsuO?t{1=k0(Hhpa z*gp{8t=^y(yFpjxb;c(5>1uZYzU{Jzd6wz7EMv*6>O)B;D)Bh3&6od}vix6<1(;)?qa;!rCU2%=B&@ z=v10Zvnb49Kha~#IZ&4Wsy54n>29?-;!CTd0Y|e`P1GZVFtpNUg=R&oyeEPzc^(!`2zs$9 zM@`jZJx03w*K)=2OZTra1MM!Y*EwT>?Nh#8pB0QLUk;zn0w2%t*_##MqBL1nC|jD! z$AJ_>q$*i0iVKzYJDW3&+=)X5WyH6HhFejhW1}PNsUG@;+QQT3o}jXczEWt`QlHpI z<`5=dZye1=C>R&^jl8AJT$YzTjrMaeCfGO-i9G{aJWhm{=QhF8k+{RfrK;ZTrTKq-+nA)wH^pj;?Be8AY2$sD>cGiZW7!Q1 z1+JlkGvWnXKr~7*E})4L3ZoI&^NmnqbaT54k;}?BM!`~71?F>dqzObMcR?*GYIqtmNLOZ^tQv+#i4?qMj_ov1RI!{n!qAN!$lmh~?V<+Z9~x;HnEr5XEUdXZPunh` z-tBy`^IETY>&gi*)cdr)9yQezUVCR{%>$IOeXC-aNh#gz0D>3Ew{&o?Bg@SQ4h8?cx4AmjHll3+$V zs_(7G)_x7-@~aTQ8TqJ|42X6{SqL$26|1UJ@oqBv+B~XR=r@@op)R&wRmlmvq$1F! zUI_|R5ps(T{i=S@dT$9p3X)r3S#)GzLxNdMG^5CHs>7rRJc5i8Rh0({q_~cQaqXVl z4Oxqk@~oJsO865zPH3zo=22G@2n-EjE^KqN8MVsz7%-Y(Pu%1XLQ&w|J%k28+`=2P zUpEVean;eUy(zjEAyV?3P8KL@P&>HM-PnbIaaDeD(mmyU71jeJBQ@VzG*u|f^W&gr zl9V{JRLYx$mv>{VxU*1p+}6hK-HhoZ!O0?-+f7n&wP} zs53sV0FPx!U)rdPN~6fi#m2D+`3U-j3<#1Urcn%UdO*=9txO0)5^{Sw=ZYL-ssIDD zBSA;A#V&f3+TqvrYugoK(tZ!1k{4`ls^CPRlH3A&{uT>?dtOpuy%>wov|e&oU@t?D zE8`8X5t>D1#XAvNS(F#V2eOjJW|1PFj=B+8#MGtrb|S9~0Du4$3Q#z;RmV{T(s02ZF+GgC{STqfmp*gSt$h_G2%>+JHm;Vz?& zhJIP|Sw~~El0jn%--FK_syosKrIMhfQ(}bXDwi?bOI-uYu9Cv>GiBUlg28C!EQ%^H zf{;q`_I$O+#rJ76!EL!yrhp}=$ho*GpRI%nRXl!u5`3A;ttp#E!zpYa8pj{0r8}13 zM#Ts=ma*bT==c2>oq+e=U=gV20MTGahg4ob0QuD+NP_`>&-+4jn7WG{%Vu1fD8VE_ z{iz&HMPYP}qpE5RlfjyEcOW40h{6NE3ZxVJ;m6@j4}DQRB2Mz{^YbH~+Xx&>?K`}W zTp1B{Q}w&58gxC^@UFVRV?6U0RR$y0q~kmdc)lF$R3wR9;8(Kj*I$WV&YDw~a@L}l%#S#ldrhzIm z=9}3cul6F)v`%?lwp*^?v3T@Vpr^|-O+F0RBN25N#K0i_Xu)|>EsSeS%Kr65QSEV9 zF9tw#)@dp5Am1148`hY?@ph9g;$eec|4a>3EjE=j#~Pm~-U6zHcDD7(>c&}un5Pa+ zhFk}?KW{3xn-NGe@fQ4Buh2G1d|cKgX*Uj?q(7c?;V)=vCmf!!Ntn(kV!#+v7iXpk z^UM9@Q(edC2J3lsEf6jkk2(~X8ECJ4GbMN_@i4^)#X|Y$@105oO%WOFC~6gUM5H1X zNo;G&GV{N>rx|qT3oV`z$M?hFR@SSx0%<^C<5-XsJILsneXAEj#F33RRO+k-Jo%#} zESb3O)hamKcgzo7QEKHH>n9%%I7yUqc`PV^MfuOjE(RCyn3eTx-OIno z1vQtJ;xW!dx}x{^#Q|lq>LO9AG_2!fCM?R@v(* z6F@ew#FuPTRZn3N$Gxs5;TYD^1pWTi=WpT0krDpe;VwV!O4yZ49i%&qcD8C01QO-C z3zaF$ncqt753o-`)&;+47O`$V<)z(q8bm@hVrc!l-s6l0v(CRUY{tY|_s$-SA371{lO zCH-63u`&^IWl?BvC%fiA%2ywjtrbLQ(Zy$iiKt1YQcOJD&hVy{#=WbSJG!!s^#cBp zuO?~3;8i)RCmRI1xOZb`?A-?p`Ij~Cw>nw-JLL+uP2bUI&gl}!Ppdd{LXronLI`N? zxQT_WmW0P_6L2>_;gipRH*>!jII?WH89J}%u2c0H2HaaX|X5q1Mqh``(e{| zFcb;^AnTu|vk5<`?teOMe-QP*a^C-NT>cx@`@ikK{uy8YUE}%3+W+uf9E?rP9i2Ws z+W%(#zkS+94PDy;aU|a+eo_7`t7D`O*Z74=;6W07;~@>bmLJ$v2t8*Quwe8Jya_Kl z51{7>U!B!}qy3HKR`v;rfB2|;{itX?JObZ*Rg)w_lMXG=Xy(RYE@WKvatbYgcQ!1@akmhwM8JZGX%4MlJoJ)EcQgO>}GT0UaV{qGeb7u89);BA^HTp;GdKs6emP3c*;}eyg~XTYr87UA zZih}wA9SKnpS@5Tm-Fq}2+*NGJU#r3 zWHyJ{ctSPecY5T$**wumQyByWfu;Z$P0ivs7G%PFCwQv(h03l6ZDzVjUb%Tj?JR2e zL7H#OsUp=E7OW09Aqk!G3SfB&A3pQo^DL>vJp>sw6GTZ6_G5~%G%hIfq?t;uE2XSJ`P|$4z%n5aAoj^48yVxiQWdWCQM528blkjMvY#MRVrM!eg z;9CXzeWAP+_j7>>F9t#&?SzlenLAFm7*l6wc04hRRqL59M4J0`;?FNaCQC~TFCYJBNJbQsM7U@aih-tg25T#cY|hf#FKIKww}sxyeaRR)g*8_;uy%c{kbw?J1cg&~QaE81* zqAGzr(a3G`tY^S)^g3C6riMNJ z%PzkcBHlQ95A|+WH`TH)_G)2KQ&_6()@&7a#i_W-<*?6np;yT0gG4AtVbD7e7mlM5 zYwtcJ;0J#cE8_N0ubp9a`7Ej)bVa3e<&GCY{Yd125sC^EBrHM;)&@0C0xjD9<*0)Q zEOA9c93#T|x@TDisv26iI^!2?Dw&-A&G-^%AJ};ZKSn`(>@(Z*IDJ*HL=w#AJ`9%6 z_aal&BMMnntkg*JIK^90Q|ueAoFNUG#_sc;Auzemv{yCWc#NXnFa!i0k6g-4fO&(t zhP^^2%a`OWHpY~y_eBMPO_CDP;ou1IP6_kd2^@j_jZ~%wkqtVK>s-_>gEMyOn$FHI zc7{{=xr^!s0juRCIRX|?KyG7TgFJ%ANzE@uq`g*r#$^QQa*UEeYfSWr52Qq!hvi|% zwIY{U-+e}%3dIufoW$QNBT^~S^KVcUfb!tzV~}t5w3YY+Ia=h$v_NC<$as$yg;Ob3 z8T?XB_r~>OOK|y;A34fVaNg{tnJ75;cUNcA1_`6LN83wg;&kzAW{aES{~A zn+q6oS?_?2H>_&#^K*x^#9g{3f{hoh3VPJ#EivwT3EQUs@FF-AQ!81vNS}@ykn{kb7k3YKVY1ve*56ER=cWEd`RFX9)HGteJ;Rjrc4wemQH5Q z4C7#VGa)e@?_z#qb4Xd2dNNCfVU$-NYW5u0!Cz;6&1(}RNYtxh>G&<)wMYi!m;VoX zK2LD4pZbGQoqV+2jlRL=3UdN_Qa}jeI2+=Gy_*WPSo0u?`hZPrrZww@tf91vA%ZS7D9q zvEPv?7v*@Y^0KqIgwAn$wJ!_u$74-h7Q2URGplQ7#Z0oYD!jGBMwt>`Afs^^)O1i) zU6y0D!c``@9-1bqvXnOl8V)IG$Vu;%)Y&G>zb{vP{5q4h@3#N;0~J=|A#+v$^%V{) zUVh10t)_Q%+7a>6^dpfnUu&j4@ETVQ{X=FpB*aVS(F)f@ zaEW7NqSTSx`zDpq<>FayO#hXB5-+KFpzT9$GJZ2mTcAkuo*ea z|FKm22n*&$yi08weMa;-1Jr;1J9&;Pyw81=V7Dl&Hk2Q>hTO)`v!u$fsW`0dg7XGb z=F^0#&}uA#WES+2oVlcZnWf-2l35v|gUCG_;cJ9<_}?6>s?!aO^|S9=$v@>-~Z*bzhED=0bAR*YYQjt6$Dr-TD)LsD_i zLUBkO4M&>QlN04P5;Kr#@>&NUZrcOBrf`qHjS9l-0nl8s*@=`Aepq_*eq*kODcJ1w z1d>3!vo_Vk=)ePUSKPv6j}lE2)f=>P5mjX&LW}ClC$Qp`3e17Xh$Ug9WiVIua{^4>^Z8v?05}oCJt+h3c8}_jFb^O53KVv#l&rm<@aR`h$8DP=o44?4r6s}>XaP? zN9G{B8E|tp@H9{huQAi(K+n!ks2o! zH_50bJiEIZPrJgcY9{07>2+nkr!l^h9F*mX7jbu4d0Quq`17;tIoKsJR`k}4JPSY4 z(;KM+JwJy(npGk9cPRCDu=TGt8+vZ&XlPPKF5ht*hd%S|>$B+c>D64?<{H}Ea&Y)id&L^a`ojK5{>qQ;bh zBh{clU_=5YGBdnY7= zHYm%5MNkiZ$xidcI7c7b{)U_P3Y&Ua+E;os5~`z!?xe^k!pBoiK7EcL4H$*&Dsi!J z3=f*)YyAmIG_27mH0>`1i`aQ&?$RtqTfp-gENND_v~II&SM&q0-*$IooP?D$alrTz zFBTO5@RfGzT|9y`s3vMFTu+x;LNg{g{s+BIN(;d`pAqB2@&qO5mA1kSf-Vx-zAO^D2T?k`6qM z;Veo;Es*{!$nL~7qbP61R>>ka!cxjCuR?plURr(BU7>3=NbRh78@sfm&-Q1<=2_px zciUX5ZBY#~$<*%{&ZKD|vlfHS84~Zf>cou&F6=)Xv+JK2w}5ja3eX;(ACNY|ePjEk z!Tqqm0X_evJr-;)=;13c0D%2JZIAWe7|egw;QTun%-`kWfA5-FnOmDXIqK?L89O-r zYwqb+htKxvK25Uz?S$ZGf>LA67qq9`z)PJMEsro87`U&A&)5MFoYs&f;gfaCP84q4e{0B^mVz z>&C~9?gbC+s?^GY-ax{pi4RxdIf}gZfRJ-z6-&p5&7{6Q4Ry_`+oYja0v&vOevjcA zSx?^AG$vA{C)DKvUlQcm9Xo23+FCUR*c88l+}V31jf!(+!UoYDEBMaH&XRTo2`$VE z=c5C5V9IvzX5fJ71o@g_ulQETU}m@YR9xvhW(45US#VuXqRbF)g<+vzm!mah;DO9! zPD3q$axf7 zqBrQ%t;Nw~{n%a4tUGn*7Y`xgpg1BQ=?d1x+}++e;M^Y>;9!$VaGZZDX04bv*3Lg+N%f>G-PkVT$et+c<3^3 zm@E`zu%*y6FlggUhk zK9U4HiAq3ef>WKi5MS#Oq+c3roJJ#;6=zs^C|FO-%+nSp)hgnwy4ft~vWIjz46+V%_PiN?j*70>x?k0k|>9+%%q5YlP4u zWW7ATItnZ_L{ozM(cc6yg7nPc{m44*MJ~`$cXIh^*CsY(zlB>R`#{r>R6k^Qg=7`v zhhDv^Wx{-$F3(#g4$mF4HheM#1Nj~0cpoHh=udoDiz*jF(T%ER#0Oq$7mVt@|j^pzVhIS z6@f^(;qh?b;6-S72Zv_as&(C}X|0pS6_h}($X&((u>Gv0r;9$eBZ{LYQ!3_S%@8)q za$?2V>zmr1#PnpQiytdw8w+NI%=invp4NIj+==Li8ij5C9HM?~bJMiXX9G;7XfA0! z>ywk_OG;>{?9y7wH%|@t3;+o<=TvV$;VlO&AZ@HWVC;j53Va*ZLtHf?+aWeIex0-6 zO)SKR47e4uWpclk9jdQm@2EjV#&` zqxTn*UZ%6XVe64_{Q$_tck!)0DQ9R=0+M>WyAX~KqX5^Dh=2?Gp(T!6O zdmP|v#md*I7ba!c0vLrWCZnKFfGMx2MD8MgEW3uu;$e8uGG{X9CxhMQ|yACBGij|gWv_vmiYX2|65tehEdWt z3$LwSDpvv?wz1}XK?p_=H2JDv53>bAm*xterbQ3!ACC+rWyN{nukl12m{7cOGhcQ5 z$xoVi$UL>ozmyQh*bu;!Fz(UzhX-(knYr1%JlP`Soc$`d9!O5thQEmaYFK2O=x=Lo z{X_TX^Do(OG_(06!@0(AWQSiZd^?ie;y3y39}R5)RfkLz=P2nO;+$*nwa&a8MVV&> z6chceMq1~#(!17Bh@ntejM^>nfRHNiauB0{Z}nhK3FZGC2xFB7rxby zcOWV8rPKp-$N0Eexa=V@Mp#FDrypl44$aavRjWm2L9SBwMT#z@5*{Em8w^Kkh<)2& z_qoZNCb0)f+qsK$f~Ov#49~UTV#awOote1JI*P_%ldK6_GPzuq_QKmuuWr0)8Zg;7 zSsgyklC}Hr4IMnZq_Wb6ox6nn%ysgCK5Oo&ecZ6<@NLFk zU$wVIm&9lFAf0Fyj0>fgn^<;?AWM^<%WEcOVDzHJ)6o4kC8GeBha=60=O6OwQN3W{ ziB%B0!*QoE+_vrAz$k2S%o1a32$Aw*1(@%Tm`;(t~GVtJr=|I@R)6Gu_+BO{WX%n=>(s;^;r(iM*7cc zSgHScB;kKTEdL$I@$Ykpe@~8$j9ttPjsKNUg2uWHHalA9A`fUnq@D@&So|-JG9Yxj z`Bv&~vo#JG`iONjo=Dw%69W=N_^Z*e&yzHC{ zFP=hnmUn@as6r|}+;7?7fV#F0dEywi*-V5EggIcBDpyv`KS&I8IUslo&`P#vyS(H*vFW z55hdG@+OEkx?wh6Rr_oq2@P}5zciCXnz8NxvmzBn8N=XFl^M_9LFdvK5C+hgpP*j4 zjud$XUUcZ{6I z52B3RU2gHo9FTe^VO@!7+B_@PfS(B8vQt-qm_Iw0<`sG0Go01KolOYYw^l_&m5??# zffFo)y&5e8Pe*BYWEgaTh?1gC=y6Tix!oTxQKq3+-_=P`b8YQn%-Uj?_E_=2dZ6s6 zU40&2n`?mQjdrXV4af%Vm^x;lG9p_wG8kN?LA+J|GUJbRd53fgjiXKzYv7N1%ofUK z0ZbDbEUX%31K~AdIN`r#dXfCX>BHK`IoGQ_DtKySW;+$GfvW{L*tBXQ+o}~^_UKNMYCHn$$^!=94?SN3SeZDaeaaQezuF3`flVG zx<2(oT&O46tWyV}V-^SiG#992RH*_Q#iPR1$c-Hbj^3XO0rsIt5U<2t)jil_1HqCm z+nFw#6@5xG5}Vw<44_yl)RwS*++ACD`a<47_^KaTRJ5V

yTbu*%k9(tQD>i4t9X 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 0 { + t.Fatalf("decodeChunkData allocations per 64 chunks = %.2f, want zero", allocs) + } +} + +func TestDecodeChunkDataRejectsOversizeBeforeDecode(t *testing.T) { + cfg := DefaultLinkConfig() + cfg.applyDefaults() + s := session{cfg: cfg, buffers: NewFabricBuffers()} + raw := bytes.Repeat([]byte{0xa5}, int(cfg.MaxAcceptedChunkSize)+1) + encoded := base64.RawURLEncoding.EncodeToString(raw) + + got, errStr := s.decodeChunkData(encoded) + if got != nil || errStr != "chunk_too_large" { + t.Fatalf("decodeChunkData oversize = (%v, %q), want chunk_too_large", got, errStr) + } +} + +func TestShmringTransportReadLineIntoUsesCallerBuffer(t *testing.T) { + rx := shmringForFabricTest(t, 256) + tx := shmringForFabricTest(t, 256) + tr := NewShmringTransportWithBuffers(rx, tx, NewFabricBuffers()) + defer tr.Close() + + line := []byte(`{"type":"ping","sid":"s"}`) + writeRingForFabricTest(t, rx, append(line, '\n')) + + var dst [maxLineLen]byte + n, err := tr.ReadLineInto(dst[:]) + if err != nil { + t.Fatalf("ReadLineInto error = %v", err) + } + if string(dst[:n]) != string(line) { + t.Fatalf("ReadLineInto = %q, want %q", string(dst[:n]), string(line)) + } +} + +func shmringForFabricTest(t *testing.T, size int) *shmring.Ring { + t.Helper() + return shmring.New(size) +} + +func writeRingForFabricTest(t *testing.T, r *shmring.Ring, data []byte) { + t.Helper() + written := 0 + for written < len(data) { + p1, p2 := r.WriteAcquire() + if len(p1)+len(p2) == 0 { + t.Fatalf("ring full while writing test data") + } + n := copy(p1, data[written:]) + if n < len(data)-written && len(p2) > 0 { + n += copy(p2, data[written+n:]) + } + r.WriteCommit(n) + written += n + } +} diff --git a/services/fabric/counters.go b/services/fabric/counters.go new file mode 100644 index 0000000..8f13c25 --- /dev/null +++ b/services/fabric/counters.go @@ -0,0 +1,21 @@ +package fabric + +// FabricCounters is the compact normal-build diagnostic surface for the MCU +// Fabric link. Counters are updated by the session reactor and published with +// retained link state; they replace per-frame/per-chunk logging in release +// builds. +type FabricCounters struct { + RXLines uint64 `json:"rx_lines"` + RXLineTooLong uint64 `json:"rx_line_too_long"` + RXBadJSON uint64 `json:"rx_bad_json"` + RXFrames uint64 `json:"rx_frames"` + TXFrames uint64 `json:"tx_frames"` + TransferBegins uint64 `json:"transfer_begins"` + TransferChunks uint64 `json:"transfer_chunks"` + TransferBytes uint64 `json:"transfer_bytes"` + TransferDecodeErrors uint64 `json:"transfer_decode_errors"` + TransferDigestErrors uint64 `json:"transfer_digest_errors"` + TransferOffsetRetries uint64 `json:"transfer_offset_retries"` + TransferAborts uint64 `json:"transfer_aborts"` + TransferCompletions uint64 `json:"transfer_completions"` +} diff --git a/services/fabric/fabric.go b/services/fabric/fabric.go index 8693a32..1a40d32 100644 --- a/services/fabric/fabric.go +++ b/services/fabric/fabric.go @@ -25,10 +25,10 @@ const defaultLinkID = "mcu-uart0" // MCU-facing link. Missing fields fall back to release defaults via // applyDefaults so callers can pass `LinkConfig{}` to mean "release". type LinkConfig struct { - // ChunkSize is the expected raw-byte payload per xfer_chunk. The MCU - // is receive-only for transfers, so this is informational/validation - // only on the Go side. Release: 2048 bytes. - ChunkSize uint32 + // MaxAcceptedChunkSize is the receive-side upper bound for the raw-byte + // payload in one xfer_chunk. The sender owns the actual chunk size; the MCU + // must accept at least 2048 bytes for fabric-jsonl/1 v1. Release: 2048 bytes. + MaxAcceptedChunkSize uint32 // PhaseTimeout is the idle-chunk watchdog: an active inbound transfer // is aborted with reason="timeout" if no xfer_chunk arrives within // this window. Mirrors transfer_mgr.lua's `phase_timeout`. @@ -62,21 +62,21 @@ type LinkConfig struct { func DefaultLinkConfig() LinkConfig { return LinkConfig{ - ChunkSize: 2048, - PhaseTimeout: 15 * time.Second, - PingInterval: 10 * time.Second, - LivenessTimeout: 30 * time.Second, - TargetCallTimeout: 5 * time.Second, - MaxInboundHelpers: 64, - RPCQuantum: 4, - BulkQuantum: 1, + MaxAcceptedChunkSize: MaxAcceptedChunkSize, + PhaseTimeout: 15 * time.Second, + PingInterval: 10 * time.Second, + LivenessTimeout: 30 * time.Second, + TargetCallTimeout: 5 * time.Second, + MaxInboundHelpers: 64, + RPCQuantum: 4, + BulkQuantum: 1, } } func (c *LinkConfig) applyDefaults() { d := DefaultLinkConfig() - if c.ChunkSize == 0 { - c.ChunkSize = d.ChunkSize + if c.MaxAcceptedChunkSize == 0 { + c.MaxAcceptedChunkSize = d.MaxAcceptedChunkSize } if c.PhaseTimeout == 0 { c.PhaseTimeout = d.PhaseTimeout @@ -111,6 +111,26 @@ func newLocalSID() string { return "mcu-sid-" + bootID + "-" + strconvx.Utoa64(nextSessionID.Add(1)) } +// StageController is Fabric's narrow boundary to an updater/main staging +// owner. Fabric submits transfer bytes and observes command results; it does +// not own updater state or flash/verifier work. +type StageController interface { + BeginStreamedStage(xferID string, size uint32) (uint64, error) + WriteStreamedStage(xferID string, generation uint64, data []byte) error + CommitStreamedStage(xferID string, generation uint64) (uint32, error) + AbortStreamedStage(xferID string, generation uint64, reason string) + CancelStreamedStage(xferID string, generation uint64, reason string) +} + +// RunOptions carries optional dependencies that do not belong in the wire +// LinkConfig. Keeping the updater staging controller here makes the local +// Fabric-to-Updater boundary explicit; Fabric no longer locates the updater +// service through package-global state. +type RunOptions struct { + Buffers *FabricBuffers + StageController StageController +} + // Run starts the fabric session. Blocks until ctx is cancelled or the // transport returns an unrecoverable error. The MCU is a hello // responder (CM5 always initiates hello/hello_ack), but otherwise @@ -119,14 +139,24 @@ func newLocalSID() string { // arrives within LivenessTimeout. Mirrors session_ctl.lua at // devicecode-lua@2c88090. func Run(ctx context.Context, tr Transport, conn *bus.Connection, nodeID, peerID string, cfg LinkConfig) { + RunWithBuffers(ctx, tr, conn, nodeID, peerID, cfg, nil) +} + +func RunWithBuffers(ctx context.Context, tr Transport, conn *bus.Connection, nodeID, peerID string, cfg LinkConfig, buffers *FabricBuffers) { + RunWithOptions(ctx, tr, conn, nodeID, peerID, cfg, RunOptions{Buffers: buffers}) +} + +func RunWithOptions(ctx context.Context, tr Transport, conn *bus.Connection, nodeID, peerID string, cfg LinkConfig, opts RunOptions) { s := session{ - linkID: defaultLinkID, - nodeID: nodeID, - peerID: peerID, - localSID: newLocalSID(), - tr: tr, - conn: conn, - cfg: cfg, + linkID: defaultLinkID, + nodeID: nodeID, + peerID: peerID, + localSID: newLocalSID(), + tr: tr, + conn: conn, + cfg: cfg, + stageController: opts.StageController, + buffers: ensureFabricBuffers(opts.Buffers), } s.run(ctx) } diff --git a/services/fabric/fabric_test.go b/services/fabric/fabric_test.go index 3222a6a..7b7143d 100644 --- a/services/fabric/fabric_test.go +++ b/services/fabric/fabric_test.go @@ -229,7 +229,7 @@ func TestOversizeLineRecovery(t *testing.T) { } func TestReleaseTransferChunkFitsLineLimit(t *testing.T) { - raw := bytes.Repeat([]byte{'x'}, int(DefaultLinkConfig().ChunkSize)) + raw := bytes.Repeat([]byte{'x'}, int(DefaultLinkConfig().MaxAcceptedChunkSize)) line := marshal(protoXferChunk{ Type: msgXferChunk, XferID: "xfer-line-limit", @@ -943,11 +943,10 @@ func TestInboundCallBusyAtCapacity(t *testing.T) { // First call holds the only helper slot. The bus has no handler, so // the call sits as a pending request until timeout. sendMsg(t, cm5, protoCall{ - Type: msgCall, - ID: "c1", - Topic: []string{"rpc", "test", "noop"}, - Payload: json.RawMessage(`{}`), - TimeoutMs: 5000, + Type: msgCall, + ID: "c1", + Topic: []string{"rpc", "test", "noop"}, + Payload: json.RawMessage(`{}`), }) // Second call arrives while the helper is full → busy reply. @@ -1976,7 +1975,7 @@ func TestCallIgnoredBeforeHandshake(t *testing.T) { sendMsg(t, cm5, protoCall{ Type: "call", ID: "pre-hello-1", Topic: []string{"rpc", "hal", "dump"}, - Payload: json.RawMessage(`{}`), TimeoutMs: 5000, + Payload: json.RawMessage(`{}`), }) select { @@ -2008,7 +2007,7 @@ func TestCallImport(t *testing.T) { sendMsg(t, cm5, protoCall{ Type: "call", ID: "test-corr-1", Topic: []string{"cap", "self", "updater", "main", "rpc", "prepare-update"}, - Payload: json.RawMessage(`{"job_id":"job-prepare","expected_image_id":"mcu-dev-15.3"}`), TimeoutMs: 5000, + Payload: json.RawMessage(`{"job_id":"job-prepare","expected_image_id":"mcu-dev-15.3"}`), }) reply := readMsg[protoReply](t, cm5) @@ -2021,7 +2020,7 @@ func TestCallImport(t *testing.T) { lines := diag.snapshot() assertDiagContains(t, lines, "[fabric-rpc]", "ev call_rx", "call_id test-corr-1", "job_id job-prepare", "expected_image_id mcu-dev-15.3") assertDiagContains(t, lines, "[fabric-rpc]", "ev call_route_ok", "local_topic rpc/updater/prepare") - assertDiagContains(t, lines, "[fabric-rpc]", "ev call_dispatch_start", "timeout_ms 5000") + assertDiagContains(t, lines, "[fabric-rpc]", "ev call_dispatch_start") waitDiagContains(t, diag, "[fabric-rpc]", "ev call_reply_tx", "ok true", "sent true") } @@ -2035,7 +2034,7 @@ func TestCallNoRoute(t *testing.T) { sendMsg(t, cm5, protoCall{ Type: "call", ID: "no-route-1", Topic: []string{"unknown", "endpoint"}, - Payload: json.RawMessage(`{}`), TimeoutMs: 1000, + Payload: json.RawMessage(`{}`), }) reply := readMsg[protoReply](t, cm5) diff --git a/services/fabric/mcu_update_flow_test.go b/services/fabric/mcu_update_flow_test.go new file mode 100644 index 0000000..a6e06fc --- /dev/null +++ b/services/fabric/mcu_update_flow_test.go @@ -0,0 +1,333 @@ +package fabric + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "io" + "strings" + "sync" + "testing" + "time" + + "devicecode-go/services/updater" +) + +type integrationVerifier struct { + mu sync.Mutex + want []byte + got []byte + manifest updater.Manifest + err error +} + +func (v *integrationVerifier) Verify(r io.Reader, sink updater.SlotSink) (updater.Manifest, error) { + data, err := io.ReadAll(r) + if err != nil { + if sink != nil { + _ = sink.Abort() + } + return updater.Manifest{}, err + } + v.mu.Lock() + v.got = append([]byte(nil), data...) + want := append([]byte(nil), v.want...) + verr := v.err + v.mu.Unlock() + if verr != nil { + if sink != nil { + _ = sink.Abort() + } + return updater.Manifest{}, verr + } + if want != nil && !bytes.Equal(data, want) { + if sink != nil { + _ = sink.Abort() + } + return updater.Manifest{}, errors.New("artefact_bytes_mismatch") + } + if sink != nil { + if _, err := sink.Write(data); err != nil { + return updater.Manifest{}, err + } + if err := sink.Commit(); err != nil { + return updater.Manifest{}, err + } + } + return v.manifest, nil +} + +func (v *integrationVerifier) bytesSeen() []byte { + v.mu.Lock() + defer v.mu.Unlock() + return append([]byte(nil), v.got...) +} + +type integrationApplier struct { + mu sync.Mutex + canCalls []updater.StagedDescriptor + rebootCalls []updater.StagedDescriptor + rebootCh chan updater.StagedDescriptor +} + +func (a *integrationApplier) CanApply(d updater.StagedDescriptor) error { + a.mu.Lock() + defer a.mu.Unlock() + a.canCalls = append(a.canCalls, d) + return nil +} + +func (a *integrationApplier) ArmReboot(d updater.StagedDescriptor) error { + a.mu.Lock() + a.rebootCalls = append(a.rebootCalls, d) + ch := a.rebootCh + a.mu.Unlock() + if ch != nil { + select { + case ch <- d: + default: + } + } + return nil +} + +func (a *integrationApplier) counts() (int, int) { + a.mu.Lock() + defer a.mu.Unlock() + return len(a.canCalls), len(a.rebootCalls) +} + +func waitForMcuUpdateDone(t *testing.T, tr Transport, id string) { + t.Helper() + deadline := time.Now().Add(2 * time.Second) + for { + if time.Now().After(deadline) { + t.Fatalf("timed out waiting for xfer_done id=%s", id) + } + line, err := tr.ReadLine() + if err != nil { + t.Fatalf("ReadLine: %v", err) + } + var probe struct { + Type string `json:"type"` + XferID string `json:"xfer_id"` + Err string `json:"err"` + } + if err := json.Unmarshal(line, &probe); err != nil { + t.Fatalf("Unmarshal %q: %v", line, err) + } + switch probe.Type { + case msgXferDone: + if probe.XferID != id { + t.Fatalf("xfer_done id = %q, want %q", probe.XferID, id) + } + return + case msgXferAbort: + t.Fatalf("transfer aborted while waiting for done: %+v", probe) + } + } +} + +func sendMcuUpdateArtefact(t *testing.T, tr Transport, id string, payload []byte, chunkSizes ...int) { + t.Helper() + sendMsg(t, tr, xferBegin(id, payload, nil)) + readTransferReady(t, tr, id, 0) + writeRawLine(t, tr, `{"type":"unknown_noise","ignored":true}`) + off := 0 + for len(payload[off:]) > 0 { + n := len(payload) - off + if len(chunkSizes) > 0 { + n = chunkSizes[0] + chunkSizes = chunkSizes[1:] + if n > len(payload)-off { + n = len(payload) - off + } + } + part := payload[off : off+n] + sendMsg(t, tr, xferChunk(id, uint32(off), part)) + off += n + readTransferNeed(t, tr, id, uint32(off)) + } + sendMsg(t, tr, xferCommit(id, payload)) + waitForMcuUpdateDone(t, tr, id) +} + +func TestMCUUpdateFullWirePathStagesAndCommitsReboot(t *testing.T) { + b := newBus() + caller := b.NewConnection("caller") + observer := b.NewConnection("observer") + upSub := observer.Subscribe(updater.TopicUpdaterFact) + defer observer.Unsubscribe(upSub) + + payload := []byte("signed-envelope-and-payload-for-mcu") + manifest := updater.Manifest{ + Version: "2.0.0", + BuildID: "build-2.0.0", + ImageID: "mcu-image-new", + PayloadSHA256: strings.Repeat("c", 64), + PayloadLength: uint32(len(payload)), + } + verif := &integrationVerifier{want: payload, manifest: manifest} + memMD := updater.NewMemoryMetadata() + app := &integrationApplier{rebootCh: make(chan updater.StagedDescriptor, 1)} + cancelUpdater, updaterSvc := runUpdaterForFabricTest(t, b, updater.Options{ + Verifier: verif, + Applier: app, + Metadata: memMD, + MetadataWrite: memMD, + }) + defer cancelUpdater() + prepareUpdaterForFabricTest(t, caller) + + cm5, mcu := pipePair() + ctx, cancelFabric := context.WithCancel(context.Background()) + defer cancelFabric() + go RunWithOptions(ctx, mcu, b.NewConnection("fabric"), "mcu", "bigbox-cm5", DefaultLinkConfig(), RunOptions{StageController: updaterSvc}) + bringUp(t, cm5) + + sendMcuUpdateArtefact(t, cm5, "xfer-full-path", payload, 7, 5) + waitUpdaterFactForFabricTest(t, upSub, func(f updater.UpdaterFact) bool { return f.State == updater.StateStaged }) + if got := verif.bytesSeen(); !bytes.Equal(got, payload) { + t.Fatalf("verifier saw %q, want %q", got, payload) + } + desc, ok := memMD.StagedDescriptor() + if !ok { + t.Fatal("staged descriptor not persisted") + } + if desc.Version != manifest.Version || desc.ImageID != manifest.ImageID || desc.PayloadSHA256 != manifest.PayloadSHA256 || desc.Length != manifest.PayloadLength { + t.Fatalf("staged descriptor = %+v, want manifest %+v", desc, manifest) + } + + payloadReply := requestUpdaterForFabricTest(t, caller, updater.TopicCommitRPC, updater.CommitRequest{}) + commit, ok := payloadReply.(updater.CommitReply) + if !ok || !commit.Accepted || !commit.RebootRequired { + t.Fatalf("commit reply = %#v, want accepted reboot_required", payloadReply) + } + select { + case rebootDesc := <-app.rebootCh: + if rebootDesc.ImageID != manifest.ImageID || rebootDesc.Version != manifest.Version { + t.Fatalf("reboot descriptor = %+v, want image %s version %s", rebootDesc, manifest.ImageID, manifest.Version) + } + case <-time.After(2 * time.Second): + t.Fatal("timeout waiting for ArmReboot") + } + waitUpdaterFactForFabricTest(t, upSub, func(f updater.UpdaterFact) bool { return f.State == updater.StateRebooting }) + can, reboot := app.counts() + if can != 1 || reboot != 1 { + t.Fatalf("applier calls: CanApply=%d ArmReboot=%d, want 1 and 1", can, reboot) + } +} + +func TestMCUUpdateFullWirePathCommitRejectsExpectedImageMismatch(t *testing.T) { + b := newBus() + caller := b.NewConnection("caller") + payload := []byte("firmware-bytes") + manifest := updater.Manifest{ + Version: "2.1.0", + BuildID: "build-2.1.0", + ImageID: "mcu-image-real", + PayloadSHA256: strings.Repeat("d", 64), + PayloadLength: uint32(len(payload)), + } + memMD := updater.NewMemoryMetadata() + app := &integrationApplier{rebootCh: make(chan updater.StagedDescriptor, 1)} + cancelUpdater, updaterSvc := runUpdaterForFabricTest(t, b, updater.Options{ + Verifier: &integrationVerifier{want: payload, manifest: manifest}, + Applier: app, + Metadata: memMD, + MetadataWrite: memMD, + }) + defer cancelUpdater() + prepareUpdaterForFabricTest(t, caller) + + cm5, mcu := pipePair() + ctx, cancelFabric := context.WithCancel(context.Background()) + defer cancelFabric() + go RunWithOptions(ctx, mcu, b.NewConnection("fabric"), "mcu", "bigbox-cm5", DefaultLinkConfig(), RunOptions{StageController: updaterSvc}) + bringUp(t, cm5) + + sendMcuUpdateArtefact(t, cm5, "xfer-mismatch", payload, 4) + if _, ok := memMD.StagedDescriptor(); !ok { + t.Fatal("staged descriptor not persisted before mismatch commit") + } + + payloadReply := requestUpdaterForFabricTest(t, caller, updater.TopicCommitRPC, updater.CommitRequest{ExpectedImageID: "mcu-image-other"}) + reply, ok := payloadReply.(updater.Reply) + if !ok || reply.OK || reply.Error != updater.ErrImageIDMismatch { + t.Fatalf("commit mismatch reply = %#v, want image_id_mismatch", payloadReply) + } + can, reboot := app.counts() + if can != 0 || reboot != 0 { + t.Fatalf("applier called despite image mismatch: CanApply=%d ArmReboot=%d", can, reboot) + } + select { + case d := <-app.rebootCh: + t.Fatalf("unexpected reboot after mismatch: %+v", d) + default: + } +} + +func TestMCUUpdateWireDigestMismatchCancelsLeaseAndLeavesNoStagedImage(t *testing.T) { + b := newBus() + caller := b.NewConnection("caller") + observer := b.NewConnection("observer") + upSub := observer.Subscribe(updater.TopicUpdaterFact) + defer observer.Unsubscribe(upSub) + memMD := updater.NewMemoryMetadata() + cancelUpdater, updaterSvc := runUpdaterForFabricTest(t, b, updater.Options{ + Verifier: &integrationVerifier{}, + Metadata: memMD, + MetadataWrite: memMD, + }) + defer cancelUpdater() + prepareUpdaterForFabricTest(t, caller) + + cm5, mcu := pipePair() + ctx, cancelFabric := context.WithCancel(context.Background()) + defer cancelFabric() + go RunWithOptions(ctx, mcu, b.NewConnection("fabric"), "mcu", "bigbox-cm5", DefaultLinkConfig(), RunOptions{StageController: updaterSvc}) + bringUp(t, cm5) + + payload := []byte("abcd") + bogusDigest := strings.Repeat("0", 8) + sendMsg(t, cm5, protoXferBegin{ + Type: msgXferBegin, + XferID: "xfer-digest-mismatch-real", + Target: updater.TargetUpdaterMain, + Size: uint32(len(payload)), + DigestAlg: updater.DigestAlgXXHash32, + Digest: bogusDigest, + }) + readTransferReady(t, cm5, "xfer-digest-mismatch-real", 0) + sendMsg(t, cm5, xferChunk("xfer-digest-mismatch-real", 0, payload)) + readTransferNeed(t, cm5, "xfer-digest-mismatch-real", uint32(len(payload))) + sendMsg(t, cm5, protoXferCommit{ + Type: msgXferCommit, + XferID: "xfer-digest-mismatch-real", + Size: uint32(len(payload)), + DigestAlg: updater.DigestAlgXXHash32, + Digest: bogusDigest, + }) + readTransferAbort(t, cm5, "xfer-digest-mismatch-real", "digest_mismatch") + + failed := waitUpdaterFactForFabricTest(t, upSub, func(f updater.UpdaterFact) bool { return f.State == updater.StateFailed }) + if got := strValueFabric(failed.LastError); got != "digest_mismatch" { + t.Fatalf("last_error = %q, want digest_mismatch", got) + } + if _, ok := memMD.StagedDescriptor(); ok { + t.Fatal("digest mismatch left a staged descriptor") + } + payloadReply := requestUpdaterForFabricTest(t, caller, updater.TopicCommitRPC, updater.CommitRequest{}) + reply, ok := payloadReply.(updater.Reply) + if !ok || reply.OK || reply.Error != updater.ErrNoStagedImage { + t.Fatalf("commit after digest mismatch = %#v, want no_staged_image", payloadReply) + } +} + +func strValueFabric(p *string) string { + if p == nil { + return "" + } + return *p +} diff --git a/services/fabric/protocol.go b/services/fabric/protocol.go index cf2b5a5..d13a17a 100644 --- a/services/fabric/protocol.go +++ b/services/fabric/protocol.go @@ -1,6 +1,9 @@ package fabric -import "encoding/json" +import ( + "encoding/json" + "strconv" +) // ---- Wire message type identifiers ---- // @@ -73,11 +76,10 @@ type protoUnretain struct { } type protoCall struct { - Type string `json:"type"` - ID string `json:"id"` - Topic []string `json:"topic"` - Payload json.RawMessage `json:"payload"` - TimeoutMs int `json:"timeout_ms"` + Type string `json:"type"` + ID string `json:"id"` + Topic []string `json:"topic"` + Payload json.RawMessage `json:"payload"` } // protoReply mirrors Lua's reply frame: {type, id, ok, payload, err}. The Go @@ -163,6 +165,361 @@ func marshal(v any) []byte { return append(b, '\n') } +// marshalHelloAck returns a compact hello_ack frame without using reflection. +func marshalHelloAck(sid, node string) []byte { + b := make([]byte, 0, 96) + b = append(b, `{"type":"hello_ack","proto":"fabric-jsonl/1","sid":"`...) + b = appendJSONString(b, sid) + b = append(b, `","node":"`...) + b = appendJSONString(b, node) + b = append(b, `"}`...) + return append(b, '\n') +} + +func marshalPing(sid string) []byte { return marshalSIDControl(msgPing, sid) } +func marshalPong(sid string) []byte { return marshalSIDControl(msgPong, sid) } + +func marshalSIDControl(typ, sid string) []byte { + b := make([]byte, 0, 48+len(sid)) + b = append(b, `{"type":"`...) + b = appendJSONString(b, typ) + b = append(b, `","sid":"`...) + b = appendJSONString(b, sid) + b = append(b, `"}`...) + return append(b, '\n') +} + +func marshalReplyErr(id, errText string) []byte { + b := make([]byte, 0, 64+len(id)+len(errText)) + b = append(b, `{"type":"reply","id":"`...) + b = appendJSONString(b, id) + b = append(b, `","ok":false,"err":"`...) + b = appendJSONString(b, errText) + b = append(b, `"}`...) + return append(b, '\n') +} + +func marshalReplyOKRaw(id string, payload json.RawMessage) []byte { + b := make([]byte, 0, 48+len(id)+len(payload)) + b = append(b, `{"type":"reply","id":"`...) + b = appendJSONString(b, id) + b = append(b, `","ok":true`...) + if len(payload) > 0 { + b = append(b, `,"payload":`...) + b = append(b, payload...) + } + b = append(b, `}`...) + return append(b, '\n') +} + +func marshalXferReady(id string) []byte { return marshalXferControl(msgXferReady, id, 0, false, "") } +func marshalXferNeed(id string, next uint32) []byte { + return marshalXferControl(msgXferNeed, id, next, true, "") +} +func marshalXferDone(id string) []byte { return marshalXferControl(msgXferDone, id, 0, false, "") } +func marshalXferAbort(id, reason string) []byte { + return marshalXferControl(msgXferAbort, id, 0, false, reason) +} + +func marshalXferControl(typ, id string, next uint32, hasNext bool, errText string) []byte { + b := make([]byte, 0, 80+len(id)+len(errText)) + b = append(b, `{"type":"`...) + b = appendJSONString(b, typ) + b = append(b, `","xfer_id":"`...) + b = appendJSONString(b, id) + b = append(b, `"`...) + if hasNext { + b = append(b, `,"next":`...) + b = strconv.AppendUint(b, uint64(next), 10) + } + if errText != "" { + b = append(b, `,"err":"`...) + b = appendJSONString(b, errText) + b = append(b, `"`...) + } + b = append(b, `}`...) + return append(b, '\n') +} + +func appendJSONString(dst []byte, s string) []byte { + for i := 0; i < len(s); i++ { + c := s[i] + if c == '\\' || c == '"' { + dst = append(dst, '\\') + } + dst = append(dst, c) + } + return dst +} + +// protoTopRaw returns the complete top-level JSON value for field. +func protoTopRaw(line []byte, field string) (json.RawMessage, bool) { + i, ok := findTopJSONValue(line, field) + if !ok { + return nil, false + } + end, ok := skipJSONValue(line, i) + if !ok || end < i || end > len(line) { + return nil, false + } + out := make(json.RawMessage, end-i) + copy(out, line[i:end]) + return out, true +} + +func protoTopUint32(line []byte, field string) (uint32, bool) { + i, ok := findTopJSONValue(line, field) + if !ok || i >= len(line) || line[i] < '0' || line[i] > '9' { + return 0, false + } + var v uint32 + for i < len(line) && line[i] >= '0' && line[i] <= '9' { + d := uint32(line[i] - '0') + if v > (1<<32-1-d)/10 { + return 0, false + } + v = v*10 + d + i++ + } + return v, true +} + +func findTopJSONValue(line []byte, field string) (int, bool) { + n := len(line) + i := skipJSONSpace(line, 0) + if i >= n || line[i] != '{' { + return 0, false + } + i++ + for { + i = skipJSONSpace(line, i) + if i >= n { + return 0, false + } + switch line[i] { + case '}': + return 0, false + case ',': + i++ + continue + } + if line[i] != '"' { + return 0, false + } + keyStart := i + 1 + keyEnd, ok := scanJSONString(line, i) + if !ok { + return 0, false + } + i = keyEnd + i = skipJSONSpace(line, i) + if i >= n || line[i] != ':' { + return 0, false + } + i++ + i = skipJSONSpace(line, i) + if i >= n { + return 0, false + } + if jsonKeyEquals(line[keyStart:keyEnd-1], field) { + return i, true + } + i, ok = skipJSONValue(line, i) + if !ok { + return 0, false + } + } +} + +func topFieldsAllowed(line []byte, allowed ...string) bool { + n := len(line) + i := skipJSONSpace(line, 0) + if i >= n || line[i] != '{' { + return false + } + i++ + for { + i = skipJSONSpace(line, i) + if i >= n { + return false + } + if line[i] == '}' { + i++ + i = skipJSONSpace(line, i) + return i == n + } + if line[i] == ',' { + i++ + continue + } + if line[i] != '"' { + return false + } + keyStart := i + 1 + keyEnd, ok := scanJSONString(line, i) + if !ok { + return false + } + if !jsonKeyInAllowed(line[keyStart:keyEnd-1], allowed) { + return false + } + i = skipJSONSpace(line, keyEnd) + if i >= n || line[i] != ':' { + return false + } + i++ + i = skipJSONSpace(line, i) + if i >= n { + return false + } + i, ok = skipJSONValue(line, i) + if !ok { + return false + } + } +} + +func jsonKeyInAllowed(key []byte, allowed []string) bool { + for _, field := range allowed { + if jsonKeyEquals(key, field) { + return true + } + } + return false +} + +func decodeHelloFast(line []byte) (protoHello, bool) { + if !topFieldsAllowed(line, "type", "proto", "sid", "node", "identity", "auth") { + return protoHello{}, false + } + var msg protoHello + msg.Type = protoTopString(line, "type") + msg.Proto = protoTopString(line, "proto") + msg.SID = protoTopString(line, "sid") + msg.Node = protoTopString(line, "node") + return msg, msg.Type == msgHello && msg.Proto != "" && msg.SID != "" +} + +func decodePingFast(line []byte, want string) (protoPing, bool) { + if !topFieldsAllowed(line, "type", "sid") { + return protoPing{}, false + } + var msg protoPing + msg.Type = protoTopString(line, "type") + msg.SID = protoTopString(line, "sid") + return msg, msg.Type == want +} + +func decodePongFast(line []byte) (protoPong, bool) { + if !topFieldsAllowed(line, "type", "sid") { + return protoPong{}, false + } + var msg protoPong + msg.Type = protoTopString(line, "type") + msg.SID = protoTopString(line, "sid") + return msg, msg.Type == msgPong +} + +func decodeCallFast(line []byte) (protoCall, bool) { + if !topFieldsAllowed(line, "type", "id", "topic", "payload") { + return protoCall{}, false + } + var msg protoCall + msg.Type = protoTopString(line, "type") + msg.ID = protoTopString(line, "id") + msg.Topic = protoTopStringArray(line, "topic") + if payload, ok := protoTopRaw(line, "payload"); ok { + msg.Payload = payload + } + return msg, msg.Type == msgCall && msg.ID != "" && len(msg.Topic) > 0 +} + +func decodeXferBeginFast(line []byte) (protoXferBegin, bool) { + if !topFieldsAllowed(line, "type", "xfer_id", "target", "size", "digest_alg", "digest", "meta") { + return protoXferBegin{}, false + } + var msg protoXferBegin + msg.Type = protoTopString(line, "type") + msg.XferID = protoTopString(line, "xfer_id") + msg.Target = protoTopString(line, "target") + msg.Size, _ = protoTopUint32(line, "size") + msg.DigestAlg = protoTopString(line, "digest_alg") + msg.Digest = protoTopString(line, "digest") + if meta, ok := protoTopRaw(line, "meta"); ok { + msg.Meta = meta + } + return msg, msg.Type == msgXferBegin && msg.XferID != "" +} + +func decodeXferChunkFast(line []byte) (protoXferChunk, bool) { + if !topFieldsAllowed(line, "type", "xfer_id", "offset", "data", "chunk_digest") { + return protoXferChunk{}, false + } + var msg protoXferChunk + msg.Type = protoTopString(line, "type") + msg.XferID = protoTopString(line, "xfer_id") + msg.Offset, _ = protoTopUint32(line, "offset") + msg.Data = protoTopString(line, "data") + msg.ChunkDigest = protoTopString(line, "chunk_digest") + return msg, msg.Type == msgXferChunk && msg.XferID != "" +} + +func decodeXferCommitFast(line []byte) (protoXferCommit, bool) { + if !topFieldsAllowed(line, "type", "xfer_id", "size", "digest_alg", "digest") { + return protoXferCommit{}, false + } + var msg protoXferCommit + msg.Type = protoTopString(line, "type") + msg.XferID = protoTopString(line, "xfer_id") + msg.Size, _ = protoTopUint32(line, "size") + msg.DigestAlg = protoTopString(line, "digest_alg") + msg.Digest = protoTopString(line, "digest") + return msg, msg.Type == msgXferCommit && msg.XferID != "" +} + +func decodeXferAbortFast(line []byte) (protoXferAbort, bool) { + if !topFieldsAllowed(line, "type", "xfer_id", "err") { + return protoXferAbort{}, false + } + var msg protoXferAbort + msg.Type = protoTopString(line, "type") + msg.XferID = protoTopString(line, "xfer_id") + msg.Err = protoTopString(line, "err") + return msg, msg.Type == msgXferAbort && msg.XferID != "" +} + +func protoTopStringArray(line []byte, field string) []string { + i, ok := findTopJSONValue(line, field) + if !ok || i >= len(line) || line[i] != '[' { + return nil + } + i++ + out := make([]string, 0, 8) + for { + i = skipJSONSpace(line, i) + if i >= len(line) { + return nil + } + if line[i] == ']' { + return out + } + if line[i] == ',' { + i++ + continue + } + if line[i] != '"' { + return nil + } + start := i + 1 + end, ok := scanJSONString(line, i) + if !ok { + return nil + } + out = append(out, string(line[start:end-1])) + i = end + } +} + // protoType extracts the wire-discriminator "type" field from a JSON // envelope via a depth-aware scan. We avoid json.Unmarshal here because // TinyGo's reflect path was observed silently leaving the field empty diff --git a/services/fabric/selftest.go b/services/fabric/selftest.go new file mode 100644 index 0000000..807c3ca --- /dev/null +++ b/services/fabric/selftest.go @@ -0,0 +1,443 @@ +//go:build !tinygo || fabric_uart_selftest + +package fabric + +import ( + "context" + "encoding/base64" + "errors" + "strconv" + "time" + + "devicecode-go/bus" + "devicecode-go/services/updater" + "devicecode-go/x/shmring" + "devicecode-go/x/xxhash" +) + +// UARTSelfTestOptions describes an opt-in in-process Fabric transfer test. It +// uses the same newline JSONL and shmring transport shape as the UART session, +// but cross-connects the rings in memory so a board can exercise Fabric and the +// updater stage-controller boundary without an external serial peer. +type UARTSelfTestOptions struct { + Conn *bus.Connection + StageController StageController + PayloadSize int + ChunkSize int + Timeout time.Duration +} + +type UARTSelfTestResult struct { + PayloadSize uint32 + ChunkSize uint32 + Digest string + XferID string +} + +func (r UARTSelfTestResult) OK() bool { return r.XferID != "" && r.PayloadSize > 0 } + +const defaultSelfTestPayloadSize = 1024 +const defaultSelfTestChunkSize = 256 +const defaultSelfTestTimeout = 10 * time.Second + +// Keep the self-test's large scratch areas out of goroutine stacks. The normal +// hardware path already keeps the MCU Fabric buffer package-level in the +// Reactor. The self-test is single-shot and opt-in, so package-level scratch is +// acceptable and avoids invalidating the 3 KB stack gate. +var selfTestMCUBuffers FabricBuffers +var selfTestPeerLine [maxLineLen]byte +var selfTestB64 [maxChunkBase64Len]byte + +// RunUARTSelfTest starts an MCU Fabric session and a tiny in-process CM5 peer +// connected by cross-wired shmring transports. It performs prepare-update and a +// transfer to updater/main, then stops before commit-update/reboot. This is a +// hardware smoke gate for Fabric framing and the updater stage-controller seam; +// it is not a production A/B flash test. +func RunUARTSelfTest(ctx context.Context, opts UARTSelfTestOptions) (UARTSelfTestResult, error) { + if opts.Conn == nil { + return UARTSelfTestResult{}, errors.New("missing_bus_connection") + } + if opts.StageController == nil { + return UARTSelfTestResult{}, errors.New("missing_stage_controller") + } + payloadSize := opts.PayloadSize + if payloadSize <= 0 { + payloadSize = defaultSelfTestPayloadSize + } + chunkSize := opts.ChunkSize + if chunkSize <= 0 { + chunkSize = defaultSelfTestChunkSize + } + if chunkSize > payloadSize { + chunkSize = payloadSize + } + timeout := opts.Timeout + if timeout <= 0 { + timeout = defaultSelfTestTimeout + } + + // Cross-wired UART-shaped rings: + // peer TX -> MCU RX on a + // MCU TX -> peer RX on b + // The rings only carry this self-test's small line frames, so 2048 bytes per + // direction is enough and avoids permanently retaining another pair of full + // UART-sized rings on the MCU. + a := shmring.New(2048) + b := shmring.New(2048) + mcuTr := NewShmringTransportWithBuffers(a, b, &selfTestMCUBuffers) + peer := newUARTSelfTestPeer(b, a, "cm5-selftest-sid") + + testCtx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + defer mcuTr.Close() + defer peer.Close() + go func() { + <-testCtx.Done() + _ = mcuTr.Close() + _ = peer.Close() + }() + + fabricDone := make(chan struct{}) + go func() { + defer close(fabricDone) + RunWithOptions(testCtx, mcuTr, opts.Conn, "mcu", "bigbox-cm5", DefaultLinkConfig(), RunOptions{Buffers: &selfTestMCUBuffers, StageController: opts.StageController}) + }() + + if err := peer.writeHello(); err != nil { + return UARTSelfTestResult{}, err + } + ackLine, err := peer.waitType(msgHelloAck, "") + if err != nil { + return UARTSelfTestResult{}, err + } + if protoTopString(ackLine, "node") != "mcu" || protoTopString(ackLine, "sid") == "" { + return UARTSelfTestResult{}, errors.New("bad_hello_ack") + } + + prepareID := "selftest-prepare-1" + if err := peer.writePrepare(prepareID); err != nil { + return UARTSelfTestResult{}, err + } + replyLine, err := peer.waitReply(prepareID) + if err != nil { + return UARTSelfTestResult{}, err + } + ok, okField := protoTopBool(replyLine, "ok") + if !okField || !ok { + errText := protoTopString(replyLine, "err") + if errText != "" { + return UARTSelfTestResult{}, errors.New("prepare_failed:" + errText) + } + return UARTSelfTestResult{}, errors.New("prepare_failed") + } + + payload := selfTestPayload(payloadSize) + digest := selfTestXXHash(payload) + xferID := "selftest-xfer-1" + if err := peer.writeXferBegin(xferID, uint32(len(payload)), digest); err != nil { + return UARTSelfTestResult{}, err + } + if _, err := peer.waitType(msgXferReady, xferID); err != nil { + return UARTSelfTestResult{}, err + } + if err := peer.waitNeed(xferID, 0); err != nil { + return UARTSelfTestResult{}, err + } + + for off := 0; off < len(payload); off += chunkSize { + end := off + chunkSize + if end > len(payload) { + end = len(payload) + } + chunk := payload[off:end] + if err := peer.writeXferChunk(xferID, uint32(off), chunk); err != nil { + return UARTSelfTestResult{}, err + } + if err := peer.waitNeed(xferID, uint32(end)); err != nil { + return UARTSelfTestResult{}, err + } + } + if err := peer.writeXferCommit(xferID, uint32(len(payload)), digest); err != nil { + return UARTSelfTestResult{}, err + } + if _, err := peer.waitType(msgXferDone, xferID); err != nil { + return UARTSelfTestResult{}, err + } + + cancel() + select { + case <-fabricDone: + case <-time.After(200 * time.Millisecond): + } + + return UARTSelfTestResult{PayloadSize: uint32(len(payload)), ChunkSize: uint32(chunkSize), Digest: digest, XferID: xferID}, nil +} + +type uartSelfTestPeer struct { + rx *shmring.Ring + tx *shmring.Ring + ctx context.Context + cancel context.CancelFunc + lineBuf *[maxLineLen]byte + n int + over bool + sid string +} + +func newUARTSelfTestPeer(rx, tx *shmring.Ring, sid string) *uartSelfTestPeer { + ctx, cancel := context.WithCancel(context.Background()) + return &uartSelfTestPeer{rx: rx, tx: tx, ctx: ctx, cancel: cancel, lineBuf: &selfTestPeerLine, sid: sid} +} + +func (p *uartSelfTestPeer) Close() error { + p.cancel() + return nil +} + +func (p *uartSelfTestPeer) writeHello() error { + return p.writeLineBytes([]byte(`{"type":"hello","proto":"fabric-jsonl/1","sid":"cm5-selftest-sid","node":"bigbox-cm5"}`)) +} + +func (p *uartSelfTestPeer) writePrepare(id string) error { + b := make([]byte, 0, 192) + b = append(b, `{"type":"call","id":"`...) + b = appendJSONString(b, id) + b = append(b, `","topic":["cap","self","updater","main","rpc","prepare-update"],"payload":{"job_id":"selftest-job","target":"mcu","expected_image_id":"hwtest-image"}}`...) + return p.writeLineBytes(b) +} + +func (p *uartSelfTestPeer) writeXferBegin(id string, size uint32, digest string) error { + b := make([]byte, 0, 192) + b = append(b, `{"type":"xfer_begin","xfer_id":"`...) + b = appendJSONString(b, id) + b = append(b, `","target":"`...) + b = appendJSONString(b, updater.TargetUpdaterMain) + b = append(b, `","size":`...) + b = strconv.AppendUint(b, uint64(size), 10) + b = append(b, `,"digest_alg":"`...) + b = appendJSONString(b, updater.DigestAlgXXHash32) + b = append(b, `","digest":"`...) + b = appendJSONString(b, digest) + b = append(b, `","meta":{"source":"mcu-selftest"}}`...) + return p.writeLineBytes(b) +} + +func (p *uartSelfTestPeer) writeXferChunk(id string, off uint32, chunk []byte) error { + n := base64.RawURLEncoding.EncodedLen(len(chunk)) + if n > len(selfTestB64) { + return ErrLineTooLong + } + base64.RawURLEncoding.Encode(selfTestB64[:n], chunk) + chunkDigest := selfTestXXHash(chunk) + b := make([]byte, 0, 160+n) + b = append(b, `{"type":"xfer_chunk","xfer_id":"`...) + b = appendJSONString(b, id) + b = append(b, `","offset":`...) + b = strconv.AppendUint(b, uint64(off), 10) + b = append(b, `,"data":"`...) + b = append(b, selfTestB64[:n]...) + b = append(b, `","chunk_digest":"`...) + b = appendJSONString(b, chunkDigest) + b = append(b, `"}`...) + return p.writeLineBytes(b) +} + +func (p *uartSelfTestPeer) writeXferCommit(id string, size uint32, digest string) error { + b := make([]byte, 0, 144) + b = append(b, `{"type":"xfer_commit","xfer_id":"`...) + b = appendJSONString(b, id) + b = append(b, `","size":`...) + b = strconv.AppendUint(b, uint64(size), 10) + b = append(b, `,"digest_alg":"`...) + b = appendJSONString(b, updater.DigestAlgXXHash32) + b = append(b, `","digest":"`...) + b = appendJSONString(b, digest) + b = append(b, `"}`...) + return p.writeLineBytes(b) +} + +func (p *uartSelfTestPeer) waitType(wantType, wantXfer string) ([]byte, error) { + for { + line, err := p.readLine() + if err != nil { + return nil, err + } + mt := protoType(line) + if mt == msgXferAbort { + id := protoTopString(line, "xfer_id") + if wantXfer == "" || id == wantXfer { + return nil, errors.New("xfer_abort:" + protoTopString(line, "err")) + } + } + if mt != wantType { + continue + } + if wantXfer == "" || protoTopString(line, "xfer_id") == wantXfer { + return line, nil + } + } +} + +func (p *uartSelfTestPeer) waitReply(id string) ([]byte, error) { + for { + line, err := p.readLine() + if err != nil { + return nil, err + } + if protoType(line) == msgReply && protoTopString(line, "id") == id { + return line, nil + } + } +} + +func (p *uartSelfTestPeer) waitNeed(id string, next uint32) error { + for { + line, err := p.waitType(msgXferNeed, id) + if err != nil { + return err + } + got, ok := protoTopUint32(line, "next") + if ok && got == next { + return nil + } + if ok { + return errors.New("unexpected_xfer_need") + } + } +} + +func (p *uartSelfTestPeer) readLine() ([]byte, error) { + p.n = 0 + p.over = false + for { + p1, p2 := p.rx.ReadAcquire() + if len(p1)+len(p2) == 0 { + select { + case <-p.ctx.Done(): + return nil, errors.New("transport_closed") + case <-p.rx.Readable(): + continue + } + } + if idx := findByte(p1, '\n'); idx >= 0 { + if !p.over && !p.appendLineChunk(p1[:idx]) { + p.over = true + } + p.rx.ReadRelease(idx + 1) + return p.finishLine() + } + if !p.over && !p.appendLineChunk(p1) { + p.over = true + } + if idx := findByte(p2, '\n'); idx >= 0 { + if !p.over && !p.appendLineChunk(p2[:idx]) { + p.over = true + } + p.rx.ReadRelease(len(p1) + idx + 1) + return p.finishLine() + } + if !p.over && !p.appendLineChunk(p2) { + p.over = true + } + p.rx.ReadRelease(len(p1) + len(p2)) + } +} + +func (p *uartSelfTestPeer) appendLineChunk(b []byte) bool { + if len(b) == 0 { + return true + } + if p.n+len(b) > len(p.lineBuf) { + p.n = 0 + return false + } + copy(p.lineBuf[p.n:], b) + p.n += len(b) + return true +} + +func (p *uartSelfTestPeer) finishLine() ([]byte, error) { + if p.over { + p.n = 0 + p.over = false + return nil, ErrLineTooLong + } + return p.lineBuf[:p.n], nil +} + +func (p *uartSelfTestPeer) writeLineBytes(data []byte) error { + if len(data) > maxLineLen { + return ErrLineTooLong + } + if err := p.writeBytes(data); err != nil { + return err + } + return p.writeByte('\n') +} + +func (p *uartSelfTestPeer) writeBytes(data []byte) error { + written := 0 + for written < len(data) { + p1, p2 := p.tx.WriteAcquire() + if len(p1)+len(p2) == 0 { + select { + case <-p.ctx.Done(): + return errors.New("transport_closed") + case <-p.tx.Writable(): + continue + } + } + remaining := data[written:] + n := copy(p1, remaining) + remaining = remaining[n:] + if len(remaining) > 0 && len(p2) > 0 { + n += copy(p2, remaining) + } + p.tx.WriteCommit(n) + written += n + } + return nil +} + +func (p *uartSelfTestPeer) writeByte(c byte) error { + for { + p1, _ := p.tx.WriteAcquire() + if len(p1) == 0 { + select { + case <-p.ctx.Done(): + return errors.New("transport_closed") + case <-p.tx.Writable(): + continue + } + } + p1[0] = c + p.tx.WriteCommit(1) + return nil + } +} + +func selfTestPayload(n int) []byte { + out := make([]byte, n) + var x uint32 = 0x12345678 + for i := range out { + x = x*1664525 + 1013904223 + out[i] = byte(x >> 24) + } + return out +} + +func selfTestXXHash(data []byte) string { return xxhashHex(xxhash.Sum32(data, 0)) } + +func protoTopBool(line []byte, field string) (bool, bool) { + i, ok := findTopJSONValue(line, field) + if !ok { + return false, false + } + if i+4 <= len(line) && string(line[i:i+4]) == "true" { + return true, true + } + if i+5 <= len(line) && string(line[i:i+5]) == "false" { + return false, true + } + return false, false +} diff --git a/services/fabric/selftest_test.go b/services/fabric/selftest_test.go new file mode 100644 index 0000000..d767209 --- /dev/null +++ b/services/fabric/selftest_test.go @@ -0,0 +1,50 @@ +package fabric + +import ( + "context" + "io" + "strings" + "testing" + "time" + + "devicecode-go/bus" + "devicecode-go/services/updater" +) + +type selfTestAcceptVerifier struct{} + +func (selfTestAcceptVerifier) Verify(r io.Reader, sink updater.SlotSink) (updater.Manifest, error) { + n, err := io.Copy(sink, r) + if err != nil { + _ = sink.Abort() + return updater.Manifest{}, err + } + if err := sink.Commit(); err != nil { + return updater.Manifest{}, err + } + return updater.Manifest{Version: "selftest", BuildID: "host", ImageID: "hwtest-image", PayloadSHA256: strings.Repeat("a", 64), PayloadLength: uint32(n)}, nil +} + +func TestRunUARTSelfTest(t *testing.T) { + b := bus.NewBus(8, "+", "#") + conn := b.NewConnection("fabric-selftest") + updaterConn := b.NewConnection("updater") + mem := updater.NewMemoryMetadata() + svc := updater.New(updater.Options{ + Conn: updaterConn, + Verifier: selfTestAcceptVerifier{}, + Metadata: mem, + MetadataWrite: mem, + Identity: updater.Identity{Version: "test", Build: "build", ImageID: "old-image"}, + }) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + go svc.Run(ctx) + res, err := RunUARTSelfTest(ctx, UARTSelfTestOptions{Conn: conn, StageController: svc, PayloadSize: 512, ChunkSize: 128, Timeout: 3 * time.Second}) + if err != nil { + t.Fatalf("RunUARTSelfTest: %v", err) + } + if !res.OK() || res.PayloadSize != 512 || res.ChunkSize != 128 { + t.Fatalf("bad result: %+v", res) + } +} diff --git a/services/fabric/session.go b/services/fabric/session.go index cf7dcae..0a6208e 100644 --- a/services/fabric/session.go +++ b/services/fabric/session.go @@ -30,7 +30,12 @@ const ( statusReady = "ready" statusOpening = "opening" statusDown = "down" - lineQueueSize = 32 + // lineQueueSize is deliberately tiny on MCU builds. The UART RX ring is + // already the byte-level shock absorber; keeping only two fully decoded + // JSONL frames avoids reserving 32 * maxLineLen bytes of static RAM. This + // preserves allocation discipline without starving the reactor behind a + // large preallocated line queue. + lineQueueSize = 2 ) // ---- timeouts (local policy) ---- @@ -87,26 +92,37 @@ type outboundCall struct { type readResult struct { line []byte + slot int err error } type linkStatePayload struct { - LinkID string `json:"link_id"` - Status string `json:"status"` - Ready bool `json:"ready"` - Established bool `json:"established"` - PeerID string `json:"peer_id"` - LocalSID string `json:"local_sid"` - PeerSID string `json:"peer_sid,omitempty"` - PeerNode string `json:"peer_node,omitempty"` - PeerProto string `json:"peer_proto,omitempty"` - LastRxUnixMilli int64 `json:"last_rx_unix_ms,omitempty"` - LastTxUnixMilli int64 `json:"last_tx_unix_ms,omitempty"` - LastPongUnixMilli int64 `json:"last_pong_unix_ms,omitempty"` - InboundCalls int `json:"inbound_calls"` - OutboundCalls int `json:"outbound_calls"` - Reason string `json:"reason,omitempty"` - Err string `json:"err,omitempty"` + LinkID string `json:"link_id"` + Status string `json:"status"` + Ready bool `json:"ready"` + Established bool `json:"established"` + PeerID string `json:"peer_id"` + LocalSID string `json:"local_sid"` + PeerSID string `json:"peer_sid,omitempty"` + PeerNode string `json:"peer_node,omitempty"` + PeerProto string `json:"peer_proto,omitempty"` + LastRxUnixMilli int64 `json:"last_rx_unix_ms,omitempty"` + LastTxUnixMilli int64 `json:"last_tx_unix_ms,omitempty"` + LastPongUnixMilli int64 `json:"last_pong_unix_ms,omitempty"` + InboundCalls int `json:"inbound_calls"` + OutboundCalls int `json:"outbound_calls"` + Reason string `json:"reason,omitempty"` + Err string `json:"err,omitempty"` + Counters FabricCounters `json:"counters"` +} + +// FabricLinkObservation gives other in-process services a small, typed way to +// observe Fabric readiness without JSON-probing this package's private retained +// payload shape. The payload remains package-private so the wire/schema contract +// is still centralised here, but Telemetry and Updater can avoid reflection on +// TinyGo's hot path. +func (p linkStatePayload) FabricLinkObservation() (ready bool, peerSID string, localSID string) { + return p.Ready, p.PeerSID, p.LocalSID } // session manages the fabric link state machine over a Transport. @@ -153,6 +169,9 @@ type session struct { completedTransfers []completedTransfer pendingTargetCall *pendingTargetCall beginTransfer func(transferMeta) (transferSink, error) + stageController StageController + buffers *FabricBuffers + counters FabricCounters busSubs *bus.SubscriptionSet ctx context.Context } @@ -174,62 +193,16 @@ func (s *session) logKV(msg, key, value string) { // run is the main loop. Blocks until ctx is cancelled. func (s *session) run(ctx context.Context) { s.cfg.applyDefaults() + s.buffers = ensureFabricBuffers(s.buffers) s.ctx = ctx s.busSubs = s.conn.NewSubscriptionSet() lines := make(chan readResult, lineQueueSize) + freeSlots := make(chan int, lineQueueSize) + for i := 0; i < lineQueueSize; i++ { + freeSlots <- i + } - go func() { - defer close(lines) - lastLineAt := time.Now() - for { - started := time.Now() - line, err := s.tr.ReadLine() - now := time.Now() - readDur := now.Sub(started) - sinceLine := now.Sub(lastLineAt) - if err != nil { - if errors.Is(err, ErrLineTooLong) { - otadiag.Event( - "[fabric-rx]", "read_error", otadiag.XferNone, - otadiag.KV("reason", "line_too_long"), - otadiag.KV("read_ms", int(readDur/time.Millisecond)), - otadiag.KV("since_line_ms", int(sinceLine/time.Millisecond)), - ) - s.log("oversized line dropped") - continue - } - otadiag.Event( - "[fabric-rx]", "read_error", otadiag.XferNone, - otadiag.KV("reason", err.Error()), - otadiag.KV("read_ms", int(readDur/time.Millisecond)), - otadiag.KV("since_line_ms", int(sinceLine/time.Millisecond)), - ) - select { - case lines <- readResult{err: err}: - case <-ctx.Done(): - } - return - } - t := protoType(line) - if shouldLogFabricRead(t, readDur, sinceLine) { - otadiag.Event( - "[fabric-rx]", "read_line", protoXferID(line), - otadiag.KV("type", t), - otadiag.KV("line_len", len(line)), - otadiag.KV("read_ms", int(readDur/time.Millisecond)), - otadiag.KV("since_line_ms", int(sinceLine/time.Millisecond)), - ) - } - lastLineAt = now - cp := make([]byte, len(line)) - copy(cp, line) - select { - case lines <- readResult{line: cp}: - case <-ctx.Done(): - return - } - } - }() + go s.readLoop(ctx, lines, freeSlots) defer s.tr.Close() defer func() { @@ -274,11 +247,18 @@ func (s *session) run(ctx context.Context) { return } if res.err != nil { + s.releaseReadSlot(freeSlots, res.slot) + if errors.Is(res.err, ErrLineTooLong) { + s.counters.RXLineTooLong++ + continue + } s.handleLinkDown(reasonTransportDown, res.err.Error()) return } + s.counters.RXLines++ beforeRx := s.lastRxAt s.dispatch(res.line) + s.releaseReadSlot(freeSlots, res.slot) if s.lastRxAt.After(beforeRx) { resetTimer(stale, s.cfg.LivenessTimeout) } @@ -314,6 +294,65 @@ func (s *session) run(ctx context.Context) { } } +func (s *session) readLoop(ctx context.Context, lines chan<- readResult, freeSlots <-chan int) { + defer close(lines) + lastLineAt := time.Now() + _ = lastLineAt + for { + var slot int + select { + case slot = <-freeSlots: + case <-ctx.Done(): + return + } + started := time.Now() + _ = started + buf := s.buffers.RXLines[slot][:] + n, err := s.readTransportLine(buf) + now := time.Now() + _ = now + if err != nil { + select { + case lines <- readResult{slot: slot, err: err}: + case <-ctx.Done(): + return + } + if !errors.Is(err, ErrLineTooLong) { + return + } + continue + } + lastLineAt = now + select { + case lines <- readResult{line: buf[:n], slot: slot}: + case <-ctx.Done(): + return + } + } +} + +func (s *session) readTransportLine(dst []byte) (int, error) { + if tr, ok := s.tr.(boundedLineTransport); ok { + return tr.ReadLineInto(dst) + } + line, err := s.tr.ReadLine() + if err != nil { + return 0, err + } + if len(line) > len(dst) { + return 0, ErrLineTooLong + } + copy(dst, line) + return len(line), nil +} + +func (s *session) releaseReadSlot(freeSlots chan<- int, slot int) { + if slot < 0 { + return + } + freeSlots <- slot +} + func shouldLogFabricRead(msgType string, _, _ time.Duration) bool { switch msgType { case msgHello, msgHelloAck, msgCall, msgReply, msgXferBegin, msgXferCommit, msgXferAbort: @@ -369,6 +408,7 @@ func (s *session) publishLinkState(reason, err string) { return } status := s.currentStatus() + counters := s.counters if s.link != linkUp && (reason != "" || err != "") { status = statusDown } @@ -391,6 +431,7 @@ func (s *session) publishLinkState(reason, err string) { OutboundCalls: len(s.outboundCalls), Reason: reason, Err: err, + Counters: counters, }, true, )) @@ -400,8 +441,13 @@ func (s *session) markRx() { s.lastRxAt = time.Now() } +func (s *session) markFrameRX() { + s.counters.RXFrames++ +} + func (s *session) markTx() { s.lastTxAt = time.Now() + s.counters.TXFrames++ } func (s *session) handleLinkDown(reason, err string) { @@ -524,7 +570,13 @@ func (s *session) dispatch(line []byte) { switch t { case msgHello: - typedDispatch(s, t, line, s.onHello) + msg, ok := decodeHelloFast(line) + if !ok { + s.logMalformed(line, errors.New("bad_hello")) + return + } + s.markFrameRX() + s.onHello(&msg) return case msgHelloAck: typedDispatch(s, t, line, s.onHelloAck) @@ -537,15 +589,33 @@ func (s *session) dispatch(line []byte) { switch t { case msgPing: - typedDispatch(s, t, line, s.onPing) + msg, ok := decodePingFast(line, msgPing) + if !ok { + s.logMalformed(line, errors.New("bad_ping")) + return + } + s.markFrameRX() + s.onPing(&msg) case msgPong: - typedDispatch(s, t, line, s.onPong) + msg, ok := decodePongFast(line) + if !ok { + s.logMalformed(line, errors.New("bad_pong")) + return + } + s.markFrameRX() + s.onPong(&msg) case msgPub: typedDispatch(s, t, line, s.onPub) case msgUnretain: typedDispatch(s, t, line, s.onUnretain) case msgCall: - typedDispatch(s, t, line, s.onCall) + msg, ok := decodeCallFast(line) + if !ok { + s.logMalformed(line, errors.New("bad_call")) + return + } + s.markFrameRX() + s.onCall(&msg) case msgReply: typedDispatch(s, t, line, s.onReply) case msgXferBegin: @@ -553,13 +623,39 @@ func (s *session) dispatch(line []byte) { "[fabric-xfer]", "begin_route_start", protoXferID(line), otadiag.KV("line_len", len(line)), ) - typedDispatch(s, t, line, s.onTransferBegin) + msg, ok := decodeXferBeginFast(line) + if !ok { + s.logMalformed(line, errors.New("bad_xfer_begin")) + return + } + s.markFrameRX() + s.onTransferBegin(&msg) + otadiag.Event("[fabric-xfer]", "begin_route_done", protoXferID(line)) case msgXferChunk: - typedDispatch(s, t, line, s.onTransferChunk) + msg, ok := decodeXferChunkFast(line) + if !ok { + s.logMalformed(line, errors.New("bad_xfer_chunk")) + s.retryMalformedTransferFrame(t, line) + return + } + s.markFrameRX() + s.onTransferChunk(&msg) case msgXferCommit: - typedDispatch(s, t, line, s.onTransferCommit) + msg, ok := decodeXferCommitFast(line) + if !ok { + s.logMalformed(line, errors.New("bad_xfer_commit")) + return + } + s.markFrameRX() + s.onTransferCommit(&msg) case msgXferAbort: - typedDispatch(s, t, line, s.onTransferAbort) + msg, ok := decodeXferAbortFast(line) + if !ok { + s.logMalformed(line, errors.New("bad_xfer_abort")) + return + } + s.markFrameRX() + s.onTransferAbort(&msg) case msgXferReady, msgXferNeed, msgXferDone: s.logKV("echoed transfer control ignored", "type", t) default: @@ -601,6 +697,7 @@ func typedDispatch[T any](s *session, msgType string, line []byte, handler func( s.retryMalformedTransferFrame(msgType, line) return } + s.markFrameRX() handler(&msg) if msgType == msgXferBegin { otadiag.Event("[fabric-xfer]", "begin_route_done", protoXferID(line)) @@ -636,6 +733,7 @@ func (s *session) requireLinkUp(t string) bool { } func (s *session) logMalformed(line []byte, err error) { + s.counters.RXBadJSON++ errStr := "" if err != nil { errStr = err.Error() @@ -854,12 +952,7 @@ func (s *session) onHello(msg *protoHello) { reason := s.notePeerIdentity(msg.Node, msg.SID, msg.Proto) s.logKV("hello rx", "peer_sid", msg.SID) - if !s.sendControl(marshal(protoHelloAck{ - Type: msgHelloAck, - Proto: protocolName, - SID: s.localSID, - Node: s.nodeID, - })) { + if !s.sendControl(marshalHelloAck(s.localSID, s.nodeID)) { return } s.log("hello_ack tx") @@ -903,7 +996,7 @@ func (s *session) onPing(msg *protoPing) { } s.markRx() s.logKV("ping rx", "peer_sid", msg.SID) - if !s.sendControl(marshal(protoPong{Type: msgPong, SID: s.localSID})) { + if !s.sendControl(marshalPong(s.localSID)) { return } s.log("pong tx") @@ -919,7 +1012,7 @@ func (s *session) tickPing(now time.Time) { if s.nextPingAt.IsZero() || now.Before(s.nextPingAt) { return } - if !s.sendControl(marshal(protoPing{Type: msgPing, SID: s.localSID})) { + if !s.sendControl(marshalPing(s.localSID)) { return } s.nextPingAt = now.Add(s.cfg.PingInterval) @@ -988,24 +1081,22 @@ func (s *session) onCall(msg *protoCall) { if !validWireTopic(msg.Topic) { s.rpcDiag("call_reject", msg, nil, "bad_topic") s.log("incoming call dropped: bad_topic") - s.sendRPC(marshal(protoReply{Type: msgReply, Corr: msg.ID, OK: false, Err: "bad_topic"})) + s.sendRPC(marshalReplyErr(msg.ID, "bad_topic")) return } - s.rpcDiag("call_rx", msg, nil, "", - otadiag.KV("timeout_ms", strconvx.Itoa(msg.TimeoutMs)), - ) + s.rpcDiag("call_rx", msg, nil, "") for _, call := range s.inboundCalls { if call.id == msg.ID { s.rpcDiag("call_reject", msg, nil, "duplicate_call_id") s.logKV("incoming call dropped", "err", "duplicate_call_id") - s.sendRPC(marshal(protoReply{Type: msgReply, Corr: msg.ID, OK: false, Err: "duplicate_call_id"})) + s.sendRPC(marshalReplyErr(msg.ID, "duplicate_call_id")) return } } if len(s.inboundCalls) >= s.cfg.MaxInboundHelpers { s.rpcDiag("call_reject", msg, nil, reasonBusy) s.log("incoming call dropped: busy") - s.sendRPC(marshal(protoReply{Type: msgReply, Corr: msg.ID, OK: false, Err: reasonBusy})) + s.sendRPC(marshalReplyErr(msg.ID, reasonBusy)) return } @@ -1013,20 +1104,15 @@ func (s *session) onCall(msg *protoCall) { if localTopic == nil { s.rpcDiag("call_reject", msg, nil, reasonNoRoute) s.log("incoming call dropped: no_route") - s.sendRPC(marshal(protoReply{Type: msgReply, Corr: msg.ID, OK: false, Err: reasonNoRoute})) + s.sendRPC(marshalReplyErr(msg.ID, reasonNoRoute)) return } s.rpcDiag("call_route_ok", msg, localTopic, "") s.markRx() timeout := callTimeoutDef - if msg.TimeoutMs > 0 { - timeout = time.Duration(msg.TimeoutMs) * time.Millisecond - } busMsg := s.conn.NewMessage(localTopic, msg.Payload, false) - s.rpcDiag("call_dispatch_start", msg, localTopic, "", - otadiag.KV("timeout_ms", strconvx.Itoa(int(timeout/time.Millisecond))), - ) + s.rpcDiag("call_dispatch_start", msg, localTopic, "") sub := s.requestBus(busMsg) topicCopy := append([]string(nil), msg.Topic...) call := &inboundCall{ @@ -1476,22 +1562,22 @@ func (s *session) handleInboundReplyEvent(id string, reply *bus.Message, closed } s.removeInboundCall(idx) if closed || reply == nil { - sent := s.sendRPC(marshal(protoReply{Type: msgReply, Corr: call.id, OK: false, Err: reasonTimeout})) + sent := s.sendRPC(marshalReplyErr(call.id, reasonTimeout)) s.rpcDiagInbound("call_reply_tx", call, false, reasonTimeout, otadiag.KV("sent", sent)) return } if errStr := checkBusError(reply.Payload); errStr != "" { - sent := s.sendRPC(marshal(protoReply{Type: msgReply, Corr: call.id, OK: false, Err: errStr})) + sent := s.sendRPC(marshalReplyErr(call.id, errStr)) s.rpcDiagInbound("call_reply_tx", call, false, errStr, otadiag.KV("sent", sent)) return } payload, err := marshalPayload(reply.Payload) if err != nil { - sent := s.sendRPC(marshal(protoReply{Type: msgReply, Corr: call.id, OK: false, Err: errPayloadMarshal})) + sent := s.sendRPC(marshalReplyErr(call.id, errPayloadMarshal)) s.rpcDiagInbound("call_reply_tx", call, false, errPayloadMarshal, otadiag.KV("sent", sent)) return } - sent := s.sendRPC(marshal(protoReply{Type: msgReply, Corr: call.id, OK: true, Payload: payload})) + sent := s.sendRPC(marshalReplyOKRaw(call.id, payload)) s.rpcDiagInbound("call_reply_tx", call, true, "", otadiag.KV("sent", sent)) } @@ -1506,7 +1592,7 @@ func (s *session) expireInbound(now time.Time) { s.conn.Unsubscribe(call.sub) call.sub = nil } - sent := s.sendRPC(marshal(protoReply{Type: msgReply, Corr: call.id, OK: false, Err: reasonTimeout})) + sent := s.sendRPC(marshalReplyErr(call.id, reasonTimeout)) s.rpcDiagInbound("call_reply_tx", call, false, reasonTimeout, otadiag.KV("sent", sent)) continue } @@ -1560,11 +1646,10 @@ func (s *session) handleOutboundCallEvent(now time.Time, msg *bus.Message) { }) } _ = s.sendRPC(marshal(protoCall{ - Type: msgCall, - ID: corr, - Topic: wireTopic, - Payload: payload, - TimeoutMs: int(callTimeoutDef / time.Millisecond), + Type: msgCall, + ID: corr, + Topic: wireTopic, + Payload: payload, })) } diff --git a/services/fabric/transfer.go b/services/fabric/transfer.go index a6fbb03..ea422cd 100644 --- a/services/fabric/transfer.go +++ b/services/fabric/transfer.go @@ -4,7 +4,6 @@ import ( "encoding/base64" "encoding/json" "errors" - "runtime" "strings" "time" @@ -19,7 +18,6 @@ const transferTargetUpdaterMain = "updater/main" const transferIdleRetryLimit = 3 const transferCorruptRetryLimit = 3 const completedTransferCacheLimit = 4 -const transferMemSampleStride = 64 * 1024 // transferMeta captures xfer_begin contents. The transfer target is explicit // on the wire; firmware update uses target="updater/main". meta remains opaque @@ -40,6 +38,13 @@ type transferInfo struct { BytesWritten uint32 SlotXIPAddr uint32 Generation uint64 + cancel func(reason string) +} + +func (i transferInfo) cancelStage(reason string) { + if i.cancel != nil { + i.cancel(reason) + } } // transferSink is the firmware-side write target for an incoming transfer. @@ -191,96 +196,39 @@ func (w *transferSinkWorker) run(sink transferSink) { } func (w *transferSinkWorker) runWrite(sink transferSink, cmd transferSinkCommand) bool { - opDone := make(chan error, 1) - go func() { - opDone <- sink.WriteChunk(cmd.offset, cmd.data) - }() - timer, timerCh := newOptionalWorkerTimer(cmd.timeout) - defer stopOptionalWorkerTimer(timer) - - select { - case err := <-opDone: - if err != nil { - _ = sink.Abort(err.Error()) - cmd.chunkResult <- transferChunkResult{err: err} - return false - } - gcStart := time.Now() - next := cmd.offset + uint32(len(cmd.data)) - otadiag.Event("[fabric-xfer]", "gc_start", cmd.xferID, otadiag.KV("next", u32s(next))) - runtime.GC() - otadiag.Event( - "[fabric-xfer]", "gc_done", cmd.xferID, - otadiag.KV("dur_ms", int(time.Since(gcStart)/time.Millisecond)), - otadiag.KV("next", next), - ) - cmd.chunkResult <- transferChunkResult{} - return true - case <-timerCh: + start := time.Now() + err := sink.WriteChunk(cmd.offset, cmd.data) + if err != nil { + _ = sink.Abort(err.Error()) + cmd.chunkResult <- transferChunkResult{err: err} + return false + } + if cmd.timeout > 0 && time.Since(start) > cmd.timeout { reason := "chunk_write_timeout" - cmd.chunkResult <- transferChunkResult{err: errors.New(reason)} - <-opDone _ = sink.Abort(reason) - return false - case abort := <-w.cmdCh: - if abort.kind != transferSinkCommandAbort { - cmd.chunkResult <- transferChunkResult{err: errors.New("transfer_worker_protocol_error")} - return false - } - <-opDone - _ = sink.Abort(abort.reason) + cmd.chunkResult <- transferChunkResult{err: errors.New(reason)} return false } + cmd.chunkResult <- transferChunkResult{} + return true } func (w *transferSinkWorker) runCommit(sink transferSink, cmd transferSinkCommand) { - opDone := make(chan transferCommitResult, 1) - go func() { - info, err := sink.Commit() - opDone <- transferCommitResult{info: info, err: err} - }() - timer, timerCh := newOptionalWorkerTimer(cmd.timeout) - defer stopOptionalWorkerTimer(timer) - - select { - case res := <-opDone: - if res.err != nil { - _ = sink.Abort(res.err.Error()) - } + start := time.Now() + info, err := sink.Commit() + res := transferCommitResult{info: info, err: err} + if err != nil { + _ = sink.Abort(err.Error()) cmd.commitResult <- res - case <-timerCh: + return + } + if cmd.timeout > 0 && time.Since(start) > cmd.timeout { reason := "transfer_commit_timeout" + info.cancelStage(reason) cmd.commitResult <- transferCommitResult{err: errors.New(reason)} - <-opDone - _ = sink.Abort(reason) - case abort := <-w.cmdCh: - if abort.kind != transferSinkCommandAbort { - cmd.commitResult <- transferCommitResult{err: errors.New("transfer_worker_protocol_error")} - return - } - <-opDone - _ = sink.Abort(abort.reason) - } -} - -func newOptionalWorkerTimer(d time.Duration) (*time.Timer, <-chan time.Time) { - if d <= 0 { - return nil, nil - } - t := time.NewTimer(d) - return t, t.C -} - -func stopOptionalWorkerTimer(t *time.Timer) { - if t == nil { return } - if !t.Stop() { - select { - case <-t.C: - default: - } - } + cmd.commitResult <- res } type completedTransfer struct { @@ -409,45 +357,52 @@ func u32s(v uint32) string { return strconvx.Itoa(int(v)) } -func decodeChunkData(encoded string) ([]byte, string) { - raw, err := base64.RawURLEncoding.DecodeString(encoded) +func (s *session) decodeChunkData(encoded string) ([]byte, string) { + s.buffers = ensureFabricBuffers(s.buffers) + maxAccepted := int(s.cfg.MaxAcceptedChunkSize) + if maxAccepted <= 0 || maxAccepted > len(s.buffers.ChunkRaw) { + maxAccepted = len(s.buffers.ChunkRaw) + } + if len(encoded) > len(s.buffers.ChunkB64) { + return nil, "chunk_too_large" + } + decodedLen := base64.RawURLEncoding.DecodedLen(len(encoded)) + if decodedLen > maxAccepted { + return nil, "chunk_too_large" + } + copy(s.buffers.ChunkB64[:], encoded) + raw := s.buffers.ChunkRaw[:maxAccepted] + n, err := base64.RawURLEncoding.Decode(raw, s.buffers.ChunkB64[:len(encoded)]) if err != nil { return nil, "invalid_chunk_encoding" } - if base64.RawURLEncoding.EncodeToString(raw) != encoded { + encLen := base64.RawURLEncoding.EncodedLen(n) + if encLen != len(encoded) || encLen > len(s.buffers.ChunkB64) { return nil, "invalid_chunk_encoding" } - return raw, "" + base64.RawURLEncoding.Encode(s.buffers.ChunkB64[:encLen], raw[:n]) + for i := 0; i < encLen; i++ { + if s.buffers.ChunkB64[i] != encoded[i] { + return nil, "invalid_chunk_encoding" + } + } + return raw[:n], "" } func (s *session) sendTransferReady(id string) bool { - return s.sendControl(marshal(protoXferReady{ - Type: msgXferReady, - XferID: id, - })) + return s.sendControl(marshalXferReady(id)) } func (s *session) sendTransferNeed(id string, next uint32) bool { - return s.sendControl(marshal(protoXferNeed{ - Type: msgXferNeed, - XferID: id, - Next: next, - })) + return s.sendControl(marshalXferNeed(id, next)) } func (s *session) sendTransferDone(id string) bool { - return s.sendControl(marshal(protoXferDone{ - Type: msgXferDone, - XferID: id, - })) + return s.sendControl(marshalXferDone(id)) } func (s *session) sendTransferAbort(id, reason string) bool { - return s.sendControl(marshal(protoXferAbort{ - Type: msgXferAbort, - XferID: id, - Err: reason, - })) + return s.sendControl(marshalXferAbort(id, reason)) } func (s *session) clearTransfer() *incomingTransfer { @@ -464,6 +419,7 @@ func (s *session) clearTransfer() *incomingTransfer { } func (s *session) abortTransfer(reason string) { + s.counters.TransferAborts++ cur := s.clearTransfer() if cur == nil { return @@ -505,6 +461,7 @@ func (s *session) checkTransferTimeout(now time.Time) { return } if cur.idleRetries < transferIdleRetryLimit { + s.counters.TransferOffsetRetries++ cur.idleRetries++ cur.deadline = now.Add(s.cfg.PhaseTimeout) s.logKV("transfer idle retry", "offset", u32s(cur.bytesWritten)) @@ -534,6 +491,7 @@ func (s *session) retryCorruptTransferFrame(reason string) bool { otadiag.Event("[fabric-xfer]", "abort_tx", id, otadiag.KV("reason", reason), otadiag.KV("ok", abortOK)) return false } + s.counters.TransferOffsetRetries++ cur.corruptRetriesAtOffset++ needOK := s.sendTransferNeed(cur.meta.ID, cur.bytesWritten) otadiag.Event( @@ -692,7 +650,9 @@ func (s *session) onTransferBegin(msg *protoXferBegin) { } beginFn := s.beginTransfer if beginFn == nil { - beginFn = beginTransfer + beginFn = func(meta transferMeta) (transferSink, error) { + return beginUpdaterTransfer(s.stageController, meta) + } } beginStart := time.Now() otadiag.Event( @@ -717,6 +677,7 @@ func (s *session) onTransferBegin(msg *protoXferBegin) { "[fabric-xfer]", "begin_transfer_done", meta.ID, otadiag.KV("dur_ms", int(time.Since(beginStart)/time.Millisecond)), ) + s.counters.TransferBegins++ s.incomingTransfer = &incomingTransfer{ meta: meta, worker: newTransferSinkWorker(meta.ID, sink), @@ -772,6 +733,8 @@ func (s *session) finishChunkWrite(now time.Time, res transferChunkResult) { _, _ = cur.hasher.Write(pending.data) cur.bytesWritten += uint32(len(pending.data)) cur.chunksSeen++ + s.counters.TransferChunks++ + s.counters.TransferBytes += uint64(len(pending.data)) cur.idleRetries = 0 cur.corruptRetryOffset = cur.bytesWritten cur.corruptRetriesAtOffset = 0 @@ -789,16 +752,6 @@ func (s *session) finishChunkWrite(now time.Time, res transferChunkResult) { otadiag.KV("ok", needOK), otadiag.KV("accepted", true), ) - if cur.bytesWritten != 0 && cur.bytesWritten%transferMemSampleStride == 0 { - var ms runtime.MemStats - runtime.ReadMemStats(&ms) - otadiag.Event( - "[fabric-xfer]", "transfer_mem_sample", cur.meta.ID, - otadiag.KV("next", cur.bytesWritten), - otadiag.KV("alloc", ms.Alloc), - otadiag.KV("heap", ms.HeapSys), - ) - } } func (s *session) startPendingTransferCommit(cur *incomingTransfer) { @@ -836,7 +789,7 @@ func (s *session) finishTransferCommit(now time.Time, res transferCommitResult) otadiag.KV("dur_ms", int(time.Since(pending.started)/time.Millisecond)), ) if reason := s.startTransferTargetCall(meta, pending.xferID, res.info); reason != "" { - updater.CancelStreamedStage(pending.xferID, res.info.Generation, reason) + res.info.cancelStage(reason) abortOK := s.sendTransferAbort(pending.xferID, reason) otadiag.Event("[fabric-xfer]", "abort_tx", pending.xferID, otadiag.KV("reason", reason), otadiag.KV("ok", abortOK)) } @@ -898,8 +851,9 @@ func (s *session) onTransferChunk(msg *protoXferChunk) { return } decodeStart := time.Now() - raw, errStr := decodeChunkData(msg.Data) + raw, errStr := s.decodeChunkData(msg.Data) if errStr != "" { + s.counters.TransferDecodeErrors++ otadiag.Event( "[fabric-xfer]", "chunk_decode_done", id, otadiag.KV("ok", false), @@ -943,6 +897,7 @@ func (s *session) onTransferChunk(msg *protoXferChunk) { digestStart := time.Now() want, ok := canonicalXXHash32Hex(msg.ChunkDigest) if !ok { + s.counters.TransferDigestErrors++ otadiag.Event( "[fabric-xfer]", "chunk_digest_done", id, otadiag.KV("ok", false), @@ -957,6 +912,7 @@ func (s *session) onTransferChunk(msg *protoXferChunk) { } got := xxhashHex(xxhash.Sum32(raw, 0)) if got != want { + s.counters.TransferDigestErrors++ otadiag.Event( "[fabric-xfer]", "chunk_digest_done", id, otadiag.KV("ok", false), @@ -1080,6 +1036,7 @@ func (s *session) finishTargetCall(call *pendingTargetCall, ok bool, reason stri } s.pendingTargetCall = nil if ok { + s.counters.TransferCompletions++ s.recordCompletedTransfer(call.meta) doneOK := s.sendTransferDone(call.xferID) otadiag.Event("[fabric-xfer]", "done_tx", call.xferID, otadiag.KV("ok", doneOK)) @@ -1089,7 +1046,7 @@ func (s *session) finishTargetCall(call *pendingTargetCall, ok bool, reason stri if reason == "" { reason = "stage_rejected" } - updater.CancelStreamedStage(call.xferID, call.info.Generation, reason) + call.info.cancelStage(reason) abortOK := s.sendTransferAbort(call.xferID, reason) otadiag.Event("[fabric-xfer]", "abort_tx", call.xferID, otadiag.KV("reason", reason), otadiag.KV("ok", abortOK)) } @@ -1120,7 +1077,7 @@ func (s *session) cancelTargetCall(reason string) { call.sub = nil } s.pendingTargetCall = nil - updater.CancelStreamedStage(call.xferID, call.info.Generation, reason) + call.info.cancelStage(reason) otadiag.Event("[fabric-xfer]", "target_call_cancel", call.xferID, otadiag.KV("reason", reason)) } diff --git a/services/fabric/transfer_sink.go b/services/fabric/transfer_sink.go new file mode 100644 index 0000000..b556d75 --- /dev/null +++ b/services/fabric/transfer_sink.go @@ -0,0 +1,67 @@ +package fabric + +import "errors" + +// streamedStageSink is the updater/main transfer sink. It keeps Fabric on the +// transfer-protocol side of the boundary: all update ownership goes through the +// explicit StageController supplied by the caller. +type streamedStageSink struct { + controller StageController + xferID string + generation uint64 + accepted uint32 + closed bool +} + +func beginUpdaterTransfer(controller StageController, meta transferMeta) (transferSink, error) { + if controller == nil { + return nil, errors.New("updater_stage_controller_missing") + } + generation, err := controller.BeginStreamedStage(meta.ID, meta.Size) + if err != nil { + return nil, err + } + return &streamedStageSink{controller: controller, xferID: meta.ID, generation: generation}, nil +} + +func (s *streamedStageSink) WriteChunk(off uint32, data []byte) error { + if s.closed { + return errors.New("sink_closed") + } + if s.accepted != off { + return errors.New("unexpected_offset") + } + if err := s.controller.WriteStreamedStage(s.xferID, s.generation, data); err != nil { + return err + } + s.accepted += uint32(len(data)) + return nil +} + +func (s *streamedStageSink) Commit() (transferInfo, error) { + if s.closed { + return transferInfo{}, errors.New("sink_closed") + } + written, err := s.controller.CommitStreamedStage(s.xferID, s.generation) + if err != nil { + return transferInfo{}, err + } + s.closed = true + return transferInfo{ + BytesWritten: written, + Generation: s.generation, + cancel: s.cancelAfterCommit, + }, nil +} + +func (s *streamedStageSink) Apply() error { return nil } + +func (s *streamedStageSink) Abort(reason string) error { + s.controller.AbortStreamedStage(s.xferID, s.generation, reason) + s.closed = true + return nil +} + +func (s *streamedStageSink) cancelAfterCommit(reason string) { + s.controller.CancelStreamedStage(s.xferID, s.generation, reason) +} diff --git a/services/fabric/transfer_sink_rp2350.go b/services/fabric/transfer_sink_rp2350.go deleted file mode 100644 index 6d9f5fc..0000000 --- a/services/fabric/transfer_sink_rp2350.go +++ /dev/null @@ -1,58 +0,0 @@ -//go:build tinygo && rp2350 - -package fabric - -import ( - "errors" - - "devicecode-go/services/updater" -) - -type streamedStageSink struct { - xferID string - generation uint64 - accepted uint32 - closed bool -} - -func beginTransfer(meta transferMeta) (transferSink, error) { - generation, err := updater.BeginStreamedStage(meta.ID, meta.Size) - if err != nil { - return nil, err - } - return &streamedStageSink{xferID: meta.ID, generation: generation}, nil -} - -func (s *streamedStageSink) WriteChunk(off uint32, data []byte) error { - if s.closed { - return errors.New("sink_closed") - } - if s.accepted != off { - return errors.New("unexpected_offset") - } - if err := updater.WriteStreamedStage(s.xferID, s.generation, data); err != nil { - return err - } - s.accepted += uint32(len(data)) - return nil -} - -func (s *streamedStageSink) Commit() (transferInfo, error) { - if s.closed { - return transferInfo{}, errors.New("sink_closed") - } - written, err := updater.CommitStreamedStage(s.xferID, s.generation) - if err != nil { - return transferInfo{}, err - } - s.closed = true - return transferInfo{BytesWritten: written, Generation: s.generation}, nil -} - -func (s *streamedStageSink) Apply() error { return nil } - -func (s *streamedStageSink) Abort(reason string) error { - updater.AbortStreamedStage(s.xferID, s.generation, reason) - s.closed = true - return nil -} diff --git a/services/fabric/transfer_sink_stub.go b/services/fabric/transfer_sink_stub.go deleted file mode 100644 index d98cd19..0000000 --- a/services/fabric/transfer_sink_stub.go +++ /dev/null @@ -1,62 +0,0 @@ -//go:build !(tinygo && rp2350) - -package fabric - -import ( - "errors" - - "devicecode-go/services/updater" -) - -// streamedStageSink is also the host/dev default. It does not retain the -// transfer as a whole-image []byte in Fabric. Host builds stream into the -// updater's host pre-stage implementation; RP2350 builds use the hardware -// implementation in transfer_sink_rp2350.go. -type streamedStageSink struct { - xferID string - generation uint64 - accepted uint32 - closed bool -} - -func beginTransfer(meta transferMeta) (transferSink, error) { - generation, err := updater.BeginStreamedStage(meta.ID, meta.Size) - if err != nil { - return nil, err - } - return &streamedStageSink{xferID: meta.ID, generation: generation}, nil -} - -func (s *streamedStageSink) WriteChunk(off uint32, data []byte) error { - if s.closed { - return errors.New("sink_closed") - } - if s.accepted != off { - return errors.New("unexpected_offset") - } - if err := updater.WriteStreamedStage(s.xferID, s.generation, data); err != nil { - return err - } - s.accepted += uint32(len(data)) - return nil -} - -func (s *streamedStageSink) Commit() (transferInfo, error) { - if s.closed { - return transferInfo{}, errors.New("sink_closed") - } - written, err := updater.CommitStreamedStage(s.xferID, s.generation) - if err != nil { - return transferInfo{}, err - } - s.closed = true - return transferInfo{BytesWritten: written, Generation: s.generation}, nil -} - -func (s *streamedStageSink) Apply() error { return nil } - -func (s *streamedStageSink) Abort(reason string) error { - updater.AbortStreamedStage(s.xferID, s.generation, reason) - s.closed = true - return nil -} diff --git a/services/fabric/transfer_test.go b/services/fabric/transfer_test.go index d06233e..fe355f4 100644 --- a/services/fabric/transfer_test.go +++ b/services/fabric/transfer_test.go @@ -420,6 +420,49 @@ func readTransferNeed(t *testing.T, tr Transport, id string, next uint32) { } } +func readUntilTransferAbort(t *testing.T, tr Transport, id, reason string) { + t.Helper() + deadline := time.Now().Add(2 * time.Second) + for { + if time.Now().After(deadline) { + t.Fatalf("timed out waiting for xfer_abort id=%s err=%s", id, reason) + } + lineCh := make(chan []byte, 1) + errCh := make(chan error, 1) + go func() { + line, err := tr.ReadLine() + if err != nil { + errCh <- err + return + } + lineCh <- append([]byte(nil), line...) + }() + var line []byte + select { + case line = <-lineCh: + case err := <-errCh: + t.Fatalf("ReadLine: %v", err) + case <-time.After(time.Until(deadline)): + t.Fatalf("timed out waiting for xfer_abort id=%s err=%s", id, reason) + } + var probe struct { + Type string `json:"type"` + XferID string `json:"xfer_id"` + Err string `json:"err"` + } + if err := json.Unmarshal(line, &probe); err != nil { + t.Fatalf("Unmarshal %q: %v", line, err) + } + if probe.Type != msgXferAbort { + continue + } + if probe.XferID != id || probe.Err != reason { + t.Fatalf("bad xfer_abort: %+v, want id=%s err=%s", probe, id, reason) + } + return + } +} + func readTransferAbort(t *testing.T, tr Transport, id, reason string) { t.Helper() abort := readMsg[protoXferAbort](t, tr) @@ -435,16 +478,30 @@ func writeRawLine(t *testing.T, tr Transport, line string) { } } +func TestTransferBeginWithoutStageControllerAbortsNoReady(t *testing.T) { + b := newBus() + cm5, mcu := pipePair() + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + go Run(ctx, mcu, b.NewConnection("fabric"), "mcu", "bigbox-cm5", DefaultLinkConfig()) + bringUp(t, cm5) + + payload := []byte("abcd") + sendMsg(t, cm5, xferBegin("xfer-no-controller", payload, nil)) + readTransferAbort(t, cm5, "xfer-no-controller", "updater_stage_controller_missing") +} + func TestTransferBeginWithoutPrepareAbortsNoReady(t *testing.T) { diag := captureOTADiag(t) b := newBus() - cancelUpdater, _ := runUpdaterForFabricTest(t, b, updater.Options{}) + cancelUpdater, updaterSvc := runUpdaterForFabricTest(t, b, updater.Options{}) defer cancelUpdater() cm5, mcu := pipePair() ctx, cancel := context.WithCancel(context.Background()) defer cancel() - go Run(ctx, mcu, b.NewConnection("fabric"), "mcu", "bigbox-cm5", DefaultLinkConfig()) + go RunWithOptions(ctx, mcu, b.NewConnection("fabric"), "mcu", "bigbox-cm5", DefaultLinkConfig(), RunOptions{StageController: updaterSvc}) bringUp(t, cm5) payload := []byte("abcd") @@ -460,7 +517,7 @@ func TestTransferBeginWithoutPrepareAbortsNoReady(t *testing.T) { func TestPreparedTransferBeginSendsReadyThenNeedZero(t *testing.T) { diag := captureOTADiag(t) b := newBus() - cancelUpdater, _ := runUpdaterForFabricTest(t, b, updater.Options{}) + cancelUpdater, updaterSvc := runUpdaterForFabricTest(t, b, updater.Options{}) defer cancelUpdater() caller := b.NewConnection("caller") observer := b.NewConnection("observer") @@ -471,7 +528,7 @@ func TestPreparedTransferBeginSendsReadyThenNeedZero(t *testing.T) { cm5, mcu := pipePair() ctx, cancel := context.WithCancel(context.Background()) defer cancel() - go Run(ctx, mcu, b.NewConnection("fabric"), "mcu", "bigbox-cm5", DefaultLinkConfig()) + go RunWithOptions(ctx, mcu, b.NewConnection("fabric"), "mcu", "bigbox-cm5", DefaultLinkConfig(), RunOptions{StageController: updaterSvc}) bringUp(t, cm5) payload := []byte("abcd") @@ -534,7 +591,7 @@ func TestInvalidTransferBeginEmitsRejectDiagnosticNoActiveTransfer(t *testing.T) func TestTransferAbortCancelsUpdaterLease(t *testing.T) { b := newBus() - cancelUpdater, _ := runUpdaterForFabricTest(t, b, updater.Options{}) + cancelUpdater, updaterSvc := runUpdaterForFabricTest(t, b, updater.Options{}) defer cancelUpdater() caller := b.NewConnection("caller") observer := b.NewConnection("observer") @@ -545,7 +602,7 @@ func TestTransferAbortCancelsUpdaterLease(t *testing.T) { cm5, mcu := pipePair() ctx, cancel := context.WithCancel(context.Background()) defer cancel() - go Run(ctx, mcu, b.NewConnection("fabric"), "mcu", "bigbox-cm5", DefaultLinkConfig()) + go RunWithOptions(ctx, mcu, b.NewConnection("fabric"), "mcu", "bigbox-cm5", DefaultLinkConfig(), RunOptions{StageController: updaterSvc}) bringUp(t, cm5) payload := []byte("abcd") @@ -564,7 +621,7 @@ func TestTransferAbortCancelsUpdaterLease(t *testing.T) { func TestTransferTargetRejectCancelsLeaseAndPreventsCommit(t *testing.T) { b := newBus() memMD := updater.NewMemoryMetadata() - cancelUpdater, _ := runUpdaterForFabricTest(t, b, updater.Options{ + cancelUpdater, updaterSvc := runUpdaterForFabricTest(t, b, updater.Options{ Verifier: updater.StubVerifier(), Metadata: memMD, MetadataWrite: memMD, @@ -576,7 +633,7 @@ func TestTransferTargetRejectCancelsLeaseAndPreventsCommit(t *testing.T) { cm5, mcu := pipePair() ctx, cancel := context.WithCancel(context.Background()) defer cancel() - go Run(ctx, mcu, b.NewConnection("fabric"), "mcu", "bigbox-cm5", DefaultLinkConfig()) + go RunWithOptions(ctx, mcu, b.NewConnection("fabric"), "mcu", "bigbox-cm5", DefaultLinkConfig(), RunOptions{StageController: updaterSvc}) bringUp(t, cm5) payload := []byte("abcd") @@ -596,8 +653,8 @@ func TestTransferTargetRejectCancelsLeaseAndPreventsCommit(t *testing.T) { replyPayload := requestUpdaterForFabricTest(t, caller, updater.TopicCommitRPC, updater.CommitRequest{}) reply, ok := replyPayload.(updater.Reply) - if !ok || reply.OK || reply.Error != updater.ErrNothingStaged { - t.Fatalf("commit after rejected transfer = %#v, want nothing_staged", replyPayload) + if !ok || reply.OK || reply.Error != updater.ErrNoStagedImage { + t.Fatalf("commit after rejected transfer = %#v, want no_staged_image", replyPayload) } } @@ -748,8 +805,6 @@ func TestTransferAcceptedChunkEmitsProcessingDiagnostics(t *testing.T) { []string{"[fabric-xfer]", "xfer_id xfer-chunk-diag", "ev chunk_decode_done", "ok true", "raw_len 4"}, []string{"[fabric-xfer]", "xfer_id xfer-chunk-diag", "ev chunk_digest_done", "ok true"}, []string{"[fabric-xfer]", "xfer_id xfer-chunk-diag", "ev sink_write_start", "offset 0", "raw_len 4"}, - []string{"[fabric-xfer]", "xfer_id xfer-chunk-diag", "ev gc_start", "next 4"}, - []string{"[fabric-xfer]", "xfer_id xfer-chunk-diag", "ev gc_done", "next 4"}, []string{"[fabric-xfer]", "xfer_id xfer-chunk-diag", "ev sink_write_done", "next 4"}, []string{"[fabric-xfer]", "xfer_id xfer-chunk-diag", "ev need_tx", "next 4", "ok true", "accepted true"}, ) @@ -790,39 +845,6 @@ func TestTransferNeedIsSentOnlyAfterPendingChunkWriteCompletes(t *testing.T) { } } -func TestTransferAcceptedChunkEmitsSparseMemorySample(t *testing.T) { - diag := captureOTADiag(t) - b := newBus() - cm5, mcu := pipePair() - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - - sink := &fakeTransferSink{} - go runSessionWithSink(ctx, mcu, b.NewConnection("fabric"), sink) - bringUp(t, cm5) - - payload := make([]byte, transferMemSampleStride) - for i := range payload { - payload[i] = byte(i) - } - sendMsg(t, cm5, xferBegin("xfer-mem-diag", payload, nil)) - readTransferReady(t, cm5, "xfer-mem-diag", 0) - - const chunkSize = 2048 - for off := 0; off < len(payload); off += chunkSize { - end := off + chunkSize - if end > len(payload) { - end = len(payload) - } - sendMsg(t, cm5, xferChunk("xfer-mem-diag", uint32(off), payload[off:end])) - need := readMsg[protoXferNeed](t, cm5) - if need.Next != uint32(end) { - t.Fatalf("xfer_need.next = %d, want %d", need.Next, end) - } - } - waitDiagContains(t, diag, "[fabric-xfer]", "xfer_id xfer-mem-diag", "ev transfer_mem_sample", "next 65536", "alloc", "heap") -} - func TestTransferChunkFutureOffsetRequestsCurrentAndCompletes(t *testing.T) { diag := captureOTADiag(t) b := newBus() @@ -1135,7 +1157,6 @@ func TestTransferChunkDigestMismatchRequestsSameOffset(t *testing.T) { lines := diag.snapshot() assertDiagContains(t, lines, "[fabric-xfer]", "xfer_id xfer-bad-chunk-digest", "ev chunk_digest_done", "ok false", "reason chunk_digest_mismatch") assertDiagNotContains(t, lines, "[fabric-xfer]", "xfer_id xfer-bad-chunk-digest", "ev sink_write_start") - assertDiagNotContains(t, lines, "[fabric-xfer]", "xfer_id xfer-bad-chunk-digest", "ev gc_start") sendMsg(t, cm5, xferChunk("xfer-bad-chunk-digest", 0, payload)) need = readMsg[protoXferNeed](t, cm5) @@ -1702,7 +1723,7 @@ func TestTransferCommitTimeoutCancelsLeaseAndPreventsLateStagePersist(t *testing PayloadLength: 4, }, } - cancelUpdater, _ := runUpdaterForFabricTest(t, b, updater.Options{ + cancelUpdater, updaterSvc := runUpdaterForFabricTest(t, b, updater.Options{ Verifier: verif, Metadata: memMD, MetadataWrite: memMD, @@ -1717,7 +1738,7 @@ func TestTransferCommitTimeoutCancelsLeaseAndPreventsLateStagePersist(t *testing cm5, mcu := pipePair() ctx, cancel := context.WithCancel(context.Background()) defer cancel() - go Run(ctx, mcu, b.NewConnection("fabric"), "mcu", "bigbox-cm5", cfg) + go RunWithOptions(ctx, mcu, b.NewConnection("fabric"), "mcu", "bigbox-cm5", cfg, RunOptions{StageController: updaterSvc}) bringUp(t, cm5) id := "xfer-stage-timeout" @@ -1732,13 +1753,18 @@ func TestTransferCommitTimeoutCancelsLeaseAndPreventsLateStagePersist(t *testing case <-time.After(2 * time.Second): t.Fatal("verifier did not start before commit timeout") } - readTransferAbort(t, cm5, id, "transfer_commit_timeout") + // Let the configured commit deadline pass while the verifier/flash operation + // remains blocked. The worker observes that deadline at the next safe point. + time.Sleep(50 * time.Millisecond) if _, ok := memMD.StagedDescriptor(); ok { - t.Fatal("commit timeout persisted descriptor before verifier returned") + t.Fatal("descriptor persisted while verifier was still blocked") } + // The stage worker now owns the verifier/flash call directly rather than + // spawning a nested goroutine. The timeout is therefore observed at the + // next safe point: after the bounded verifier operation returns. close(verif.release) - time.Sleep(50 * time.Millisecond) + readUntilTransferAbort(t, cm5, id, "transfer_commit_timeout") if _, ok := memMD.StagedDescriptor(); ok { t.Fatal("late verifier completion after commit timeout persisted descriptor") } diff --git a/services/fabric/transport_limits.go b/services/fabric/transport_limits.go index 8d689ad..01af198 100644 --- a/services/fabric/transport_limits.go +++ b/services/fabric/transport_limits.go @@ -3,8 +3,8 @@ package fabric import "fmt" // maxLineLen caps a single fabric frame (line-delimited JSON) end-to-end. -// It must clear the release transfer chunk: 2048 raw bytes becomes about -// 2731 base64url chars, plus JSON envelope and newline. 4096 leaves margin +// It must clear the release transfer chunk: MaxAcceptedChunkSize raw bytes becomes about +// maxChunkBase64Len base64url chars, plus JSON envelope and newline. 4096 leaves margin // while keeping malformed lines bounded. const maxLineLen = 4096 diff --git a/services/fabric/transport_shmring.go b/services/fabric/transport_shmring.go index dece826..fe8d573 100644 --- a/services/fabric/transport_shmring.go +++ b/services/fabric/transport_shmring.go @@ -10,27 +10,41 @@ import ( // ShmringTransport implements Transport over two shmring rings (RX + TX). // Used for UART0 in production (main.go). type ShmringTransport struct { - rx *shmring.Ring - tx *shmring.Ring - cancel context.CancelFunc - ctx context.Context - buf []byte - over bool // draining an oversize line + rx *shmring.Ring + tx *shmring.Ring + cancel context.CancelFunc + ctx context.Context + lineBuf *[maxLineLen]byte + n int + over bool // draining an oversize line } func NewShmringTransport(rx, tx *shmring.Ring) *ShmringTransport { + return NewShmringTransportWithBuffers(rx, tx, nil) +} + +func NewShmringTransportWithBuffers(rx, tx *shmring.Ring, buffers *FabricBuffers) *ShmringTransport { ctx, cancel := context.WithCancel(context.Background()) - return &ShmringTransport{ - rx: rx, - tx: tx, - cancel: cancel, - ctx: ctx, - buf: make([]byte, 0, 256), - } + buf := &ensureFabricBuffers(buffers).TransportLine + return &ShmringTransport{rx: rx, tx: tx, cancel: cancel, ctx: ctx, lineBuf: buf} } func (t *ShmringTransport) ReadLine() ([]byte, error) { - t.buf = t.buf[:0] + var tmp [maxLineLen]byte + n, err := t.ReadLineInto(tmp[:]) + if err != nil { + return nil, err + } + out := make([]byte, n) + copy(out, tmp[:n]) + return out, nil +} + +func (t *ShmringTransport) ReadLineInto(dst []byte) (int, error) { + if len(dst) < maxLineLen { + return 0, fmt.Errorf("fabric read buffer too small: %d", len(dst)) + } + t.n = 0 t.over = false for { @@ -38,78 +52,80 @@ func (t *ShmringTransport) ReadLine() ([]byte, error) { if len(p1)+len(p2) == 0 { select { case <-t.ctx.Done(): - return nil, fmt.Errorf("transport closed") + return 0, fmt.Errorf("transport closed") case <-t.rx.Readable(): continue } } - // Scan p1 for newline. if idx := findByte(p1, '\n'); idx >= 0 { - if !t.over { - t.buf = append(t.buf, p1[:idx]...) + if !t.over && !t.appendLineChunk(p1[:idx]) { + t.over = true } t.rx.ReadRelease(idx + 1) - if t.over { - t.buf = t.buf[:0] - t.over = false - return nil, ErrLineTooLong - } - if len(t.buf) > maxLineLen { - return nil, ErrLineTooLong - } - out := make([]byte, len(t.buf)) - copy(out, t.buf) - traceLine("rx", out) - return out, nil + return t.finishLineInto(dst) } - // No newline in p1 — consume it, check p2. - if !t.over { - t.buf = append(t.buf, p1...) + if !t.over && !t.appendLineChunk(p1) { + t.over = true } if idx := findByte(p2, '\n'); idx >= 0 { - if !t.over { - t.buf = append(t.buf, p2[:idx]...) + if !t.over && !t.appendLineChunk(p2[:idx]) { + t.over = true } t.rx.ReadRelease(len(p1) + idx + 1) - if t.over { - t.buf = t.buf[:0] - t.over = false - return nil, ErrLineTooLong - } - if len(t.buf) > maxLineLen { - return nil, ErrLineTooLong - } - out := make([]byte, len(t.buf)) - copy(out, t.buf) - traceLine("rx", out) - return out, nil + return t.finishLineInto(dst) } - // No newline — consume everything, wait for more. - if !t.over { - t.buf = append(t.buf, p2...) + if !t.over && !t.appendLineChunk(p2) { + t.over = true } t.rx.ReadRelease(len(p1) + len(p2)) + } +} - // Check for oversize. - if len(t.buf) > maxLineLen { - t.buf = t.buf[:0] - t.over = true - } +func (t *ShmringTransport) appendLineChunk(p []byte) bool { + if len(p) == 0 { + return true + } + if t.n+len(p) > maxLineLen { + t.n = 0 + return false + } + copy(t.lineBuf[t.n:], p) + t.n += len(p) + return true +} + +func (t *ShmringTransport) finishLineInto(dst []byte) (int, error) { + if t.over { + t.n = 0 + t.over = false + return 0, ErrLineTooLong } + copy(dst, t.lineBuf[:t.n]) + traceLine("rx", dst[:t.n]) + return t.n, nil } func (t *ShmringTransport) WriteLine(data []byte) error { if len(data) > maxLineLen { return ErrLineTooLong } - line := append(data, '\n') - written := 0 + if err := t.writeBytes(data); err != nil { + return err + } + if err := t.writeBytes([]byte{'\n'}); err != nil { + return err + } + traceLine("tx", data) + return nil +} - for written < len(line) { +func (t *ShmringTransport) writeBytes(data []byte) error { + written := 0 + for written < len(data) { p1, p2 := t.tx.WriteAcquire() if len(p1)+len(p2) == 0 { select { @@ -119,8 +135,7 @@ func (t *ShmringTransport) WriteLine(data []byte) error { continue } } - - remaining := line[written:] + remaining := data[written:] n := copy(p1, remaining) remaining = remaining[n:] if len(remaining) > 0 && len(p2) > 0 { @@ -129,7 +144,6 @@ func (t *ShmringTransport) WriteLine(data []byte) error { t.tx.WriteCommit(n) written += n } - traceLine("tx", data) return nil } diff --git a/services/hal/devices/serial_raw/builder.go b/services/hal/devices/serial_raw/builder.go index 8285ff8..5726798 100644 --- a/services/hal/devices/serial_raw/builder.go +++ b/services/hal/devices/serial_raw/builder.go @@ -2,16 +2,13 @@ package serial_raw import ( "context" - "runtime" "sync/atomic" "time" "devicecode-go/errcode" "devicecode-go/services/hal/internal/core" - "devicecode-go/services/otadiag" "devicecode-go/types" "devicecode-go/x/shmring" - "devicecode-go/x/strconvx" ) // ---- Parameters ---- @@ -25,12 +22,6 @@ type Params struct { TXSize int // power of two; default 512 if zero in SessionOpen } -const ( - serialRawPumpRXBudget = 256 - serialRawPumpTXBudget = 256 - serialRawPumpGapWarn = 20 * time.Millisecond -) - // ---- Device ---- type Device struct { @@ -59,38 +50,12 @@ type session struct { txHandle shmring.Handle txRing *shmring.Ring - // Reactor-owned observability. Single writer only. - rxRingFull uint32 - rxLogAt time.Time - rxLogHits uint32 - rxPressureAt time.Time - rxPressureHits uint32 - rxPumpGapAt time.Time - rxPumpGapHits uint32 - lastRXPumpAt time.Time - lastRXPumpMoved int - lastRXPumpDurMS int - lastRXPumpGapMS int - // Single worker (reactor) for the port. ctx context.Context cancel context.CancelFunc done chan struct{} } -type serialRXDiagnostics interface { - RXBuffered() int - RXBufferCap() int -} - -type serialRXErrorDiagnostics interface { - RXDropCount() uint32 - RXOverrunCount() uint32 - RXBreakCount() uint32 - RXParityCount() uint32 - RXFramingCount() uint32 -} - // ---- Builder registration ---- func Builder() core.Builder { return builder{} } @@ -204,12 +169,39 @@ func (d *Device) Control(_ core.CapAddr, verb string, payload any) (core.Enqueue } d.startSession(rxSize, txSize) - println( - "[serial-raw]", "session_open", - "uart", d.a.Name, - "rx_size", strconvx.Itoa(rxSize), - "tx_size", strconvx.Itoa(txSize), - ) + + // --- Device-level hygiene: drain spurious RX before signalling link up --- + // Discard any pre-existing or immediately-arriving bytes on the UART RX path. + // Uses a short quiet window so this remains bounded and non-blocking. + { + const quiet = 5 * time.Millisecond // time with no bytes before we stop + const maxTotal = 15 * time.Millisecond // absolute cap as a safeguard + + tmp := make([]byte, 64) + tStart := time.Now() + tQuiet := time.Now().Add(quiet) + + for { + // Non-blocking attempt to pull any pending bytes. + if n := d.port.TryRead(tmp); n > 0 { + // Extend the quiet window after activity. + tQuiet = time.Now().Add(quiet) + } else { + // No bytes right now. If we have been quiet long enough, or we have + // reached the absolute bound, stop draining. + now := time.Now() + if now.After(tQuiet) || now.Sub(tStart) >= maxTotal { + break + } + // Wait for either a UART RX edge or a very short back-off, then re-check. + select { + case <-d.port.Readable(): + case <-time.After(time.Millisecond): + } + } + } + } + // --- end hygiene --- rep := types.SerialSessionOpened{ SessionID: d.sess.id, @@ -319,213 +311,6 @@ func (d *Device) stopSession() { // ---- Reactor (single goroutine) ---- -func (d *Device) logRingFullChange(s *session, force bool) { - const rxLogMinInterval = 1 * time.Second - - hits := s.rxRingFull - - if !force { - now := time.Now() - if now.Sub(s.rxLogAt) < rxLogMinInterval { - return - } - if hits == s.rxLogHits { - return - } - s.rxLogAt = now - } else { - s.rxLogAt = time.Now() - } - - println( - "[serial-raw]", "rx_ring_full", - "uart", d.a.Name, - "hits", strconvx.Utoa64(uint64(hits)), - "ring_avail", strconvx.Itoa(s.rxRing.Available()), - "ring_space", strconvx.Itoa(s.rxRing.Space()), - "ring_cap", strconvx.Itoa(s.rxRing.Cap()), - ) - s.rxLogHits = hits -} - -func (d *Device) appendRXPumpFields(s *session, fields []otadiag.Field, now time.Time) []otadiag.Field { - if !s.lastRXPumpAt.IsZero() { - fields = append(fields, otadiag.KV("since_rx_pump_ms", int(now.Sub(s.lastRXPumpAt)/time.Millisecond))) - } - fields = append(fields, - otadiag.KV("last_pump_moved", s.lastRXPumpMoved), - otadiag.KV("last_pump_dur_ms", s.lastRXPumpDurMS), - ) - if s.lastRXPumpGapMS >= 0 { - fields = append(fields, otadiag.KV("last_pump_gap_ms", s.lastRXPumpGapMS)) - } - return fields -} - -func appendRXErrorFields(port core.SerialPort, fields []otadiag.Field) []otadiag.Field { - diag, ok := port.(serialRXErrorDiagnostics) - if !ok { - return fields - } - return append(fields, - otadiag.KV("rx_drops", diag.RXDropCount()), - otadiag.KV("rx_overrun", diag.RXOverrunCount()), - otadiag.KV("rx_break", diag.RXBreakCount()), - otadiag.KV("rx_parity", diag.RXParityCount()), - otadiag.KV("rx_framing", diag.RXFramingCount()), - ) -} - -func (d *Device) logDriverPressure(s *session, force bool) { - const minInterval = 1 * time.Second - - diag, ok := d.port.(serialRXDiagnostics) - if !ok { - return - } - used := diag.RXBuffered() - capacity := diag.RXBufferCap() - if capacity <= 0 || used < 0 { - return - } - threshold := (capacity * 3) / 4 - if threshold < 1 { - threshold = 1 - } - if !force && used < threshold { - return - } - - hits := s.rxPressureHits + 1 - now := time.Now() - if !force { - if now.Sub(s.rxPressureAt) < minInterval { - return - } - } else { - now = time.Now() - } - s.rxPressureAt = now - s.rxPressureHits = hits - - fields := []otadiag.Field{ - otadiag.KV("uart", d.a.Name), - otadiag.KV("hits", strconvx.Utoa64(uint64(hits))), - otadiag.KV("driver_used", used), - otadiag.KV("driver_cap", capacity), - otadiag.KV("ring_avail", s.rxRing.Available()), - otadiag.KV("ring_space", s.rxRing.Space()), - otadiag.KV("ring_cap", s.rxRing.Cap()), - } - fields = d.appendRXPumpFields(s, fields, now) - fields = appendRXErrorFields(d.port, fields) - otadiag.Event("[serial-raw]", "rx_driver_pressure", otadiag.XferNone, fields...) - - if !s.lastRXPumpAt.IsZero() && now.Sub(s.lastRXPumpAt) >= serialRawPumpGapWarn { - d.logRXPumpGap(s, used, capacity, now) - } -} - -func (d *Device) logRXPumpGap(s *session, used, capacity int, now time.Time) { - const minInterval = 1 * time.Second - - if now.Sub(s.rxPumpGapAt) < minInterval { - return - } - s.rxPumpGapAt = now - s.rxPumpGapHits++ - fields := []otadiag.Field{ - otadiag.KV("uart", d.a.Name), - otadiag.KV("hits", strconvx.Utoa64(uint64(s.rxPumpGapHits))), - otadiag.KV("driver_used", used), - otadiag.KV("driver_cap", capacity), - otadiag.KV("ring_avail", s.rxRing.Available()), - otadiag.KV("ring_space", s.rxRing.Space()), - otadiag.KV("ring_cap", s.rxRing.Cap()), - otadiag.KV("since_rx_pump_ms", int(now.Sub(s.lastRXPumpAt)/time.Millisecond)), - otadiag.KV("last_pump_moved", s.lastRXPumpMoved), - otadiag.KV("last_pump_dur_ms", s.lastRXPumpDurMS), - otadiag.KV("last_pump_gap_ms", s.lastRXPumpGapMS), - } - fields = appendRXErrorFields(d.port, fields) - otadiag.Event("[serial-raw]", "rx_pump_gap", otadiag.XferNone, fields...) -} - -func (s *session) noteRXPump(moved int, started time.Time) { - if moved <= 0 { - return - } - now := time.Now() - gapMS := -1 - if !s.lastRXPumpAt.IsZero() { - gapMS = int(started.Sub(s.lastRXPumpAt) / time.Millisecond) - } - s.lastRXPumpAt = now - s.lastRXPumpMoved = moved - s.lastRXPumpDurMS = int(now.Sub(started) / time.Millisecond) - s.lastRXPumpGapMS = gapMS - if s.lastRXPumpGapMS < 0 { - s.lastRXPumpGapMS = 0 - } - if s.lastRXPumpDurMS >= 5 { - otadiag.Event( - "[serial-raw]", "rx_pump_slow", otadiag.XferNone, - otadiag.KV("moved", moved), - otadiag.KV("dur_ms", s.lastRXPumpDurMS), - otadiag.KV("gap_ms", s.lastRXPumpGapMS), - ) - } -} - -func (d *Device) pumpRX(s *session, u core.SerialPort, rxR *shmring.Ring, budget int) bool { - started := time.Now() - moved := 0 - - defer func() { - if moved > 0 { - s.noteRXPump(moved, started) - } - }() - - for moved < budget { - d.logDriverPressure(s, false) - p1, p2 := rxR.WriteAcquire() - if len(p1) == 0 { - s.rxRingFull++ - break - } - - remaining := budget - moved - p1 = limitSpan(p1, remaining) - n1 := u.TryRead(p1) - if n1 == 0 { - break - } - n := n1 - moved += n1 - if n1 < len(p1) { - rxR.WriteCommit(n) - break - } - - remaining = budget - moved - if remaining > 0 && len(p2) > 0 { - p2 = limitSpan(p2, remaining) - n2 := u.TryRead(p2) - n += n2 - moved += n2 - if n2 < len(p2) { - rxR.WriteCommit(n) - break - } - } - - rxR.WriteCommit(n) - } - - return moved > 0 -} - func (d *Device) reactor(s *session) { defer close(s.done) @@ -536,30 +321,59 @@ func (d *Device) reactor(s *session) { for { made := false - if d.pumpRX(s, u, rxR, serialRawPumpRXBudget) { + // UART RX -> rxRing (use spans; fill p1 completely before p2) + for { + p1, p2 := rxR.WriteAcquire() + if len(p1) == 0 { + break + } + n1 := u.TryRead(p1) + if n1 == 0 { + break + } + if n1 < len(p1) { + rxR.WriteCommit(n1) + made = true + continue + } + n2 := 0 + if len(p2) > 0 { + n2 = u.TryRead(p2) + } + rxR.WriteCommit(n1 + n2) made = true } - if d.pumpTX(u, txR, serialRawPumpTXBudget) { + // txRing -> UART TX (use spans; drain p1 completely before p2) + for { + p1, p2 := txR.ReadAcquire() + if len(p1) == 0 { + break + } + n1 := u.TryWrite(p1) + if n1 == 0 { + break + } + if n1 < len(p1) { + txR.ReadRelease(n1) + made = true + continue + } + n2 := 0 + if len(p2) > 0 { + n2 = u.TryWrite(p2) + } + txR.ReadRelease(n1 + n2) made = true } if made { - select { - case <-s.ctx.Done(): - d.logRingFullChange(s, true) - return - default: - } - runtime.Gosched() continue } // Idle: wait for any edge, then re-check. - d.logRingFullChange(s, false) select { case <-s.ctx.Done(): - d.logRingFullChange(s, true) return case <-u.Readable(): case <-u.Writable(): @@ -569,56 +383,6 @@ func (d *Device) reactor(s *session) { } } -func (d *Device) pumpTX(u core.SerialPort, txR *shmring.Ring, budget int) bool { - moved := 0 - - for moved < budget { - p1, p2 := txR.ReadAcquire() - if len(p1) == 0 { - break - } - - remaining := budget - moved - p1 = limitSpan(p1, remaining) - n1 := u.TryWrite(p1) - if n1 == 0 { - break - } - n := n1 - moved += n1 - if n1 < len(p1) { - txR.ReadRelease(n) - break - } - - remaining = budget - moved - if remaining > 0 && len(p2) > 0 { - p2 = limitSpan(p2, remaining) - n2 := u.TryWrite(p2) - n += n2 - moved += n2 - if n2 < len(p2) { - txR.ReadRelease(n) - break - } - } - - txR.ReadRelease(n) - } - - return moved > 0 -} - -func limitSpan(p []byte, max int) []byte { - if max <= 0 { - return p[:0] - } - if len(p) > max { - return p[:max] - } - return p -} - // ---- Helpers ---- func isPow2(n int) bool { return n > 0 && (n&(n-1)) == 0 } diff --git a/services/hal/devices/serial_raw/builder_test.go b/services/hal/devices/serial_raw/builder_test.go deleted file mode 100644 index 3b71105..0000000 --- a/services/hal/devices/serial_raw/builder_test.go +++ /dev/null @@ -1,269 +0,0 @@ -package serial_raw - -import ( - "context" - "strings" - "sync" - "sync/atomic" - "testing" - "time" - - "devicecode-go/services/hal/internal/core" - "devicecode-go/services/otadiag" - "devicecode-go/types" -) - -type fakeSerialPort struct { - readable chan struct{} - writable chan struct{} - - continuousRX atomic.Bool - writeCalls atomic.Int32 - readCalls atomic.Int32 - maxReadLen atomic.Int32 - maxWriteLen atomic.Int32 - rxBuffered atomic.Int32 - rxBufferCap atomic.Int32 - rxDrops atomic.Uint32 - rxOverrun atomic.Uint32 - rxBreak atomic.Uint32 - rxParity atomic.Uint32 - rxFraming atomic.Uint32 - - mu sync.Mutex - written []byte -} - -func newFakeSerialPort() *fakeSerialPort { - p := &fakeSerialPort{ - readable: make(chan struct{}, 1), - writable: make(chan struct{}, 1), - } - p.signalReadable() - p.signalWritable() - return p -} - -func (p *fakeSerialPort) RXBuffered() int { return int(p.rxBuffered.Load()) } -func (p *fakeSerialPort) RXBufferCap() int { return int(p.rxBufferCap.Load()) } -func (p *fakeSerialPort) RXDropCount() uint32 { return p.rxDrops.Load() } -func (p *fakeSerialPort) RXOverrunCount() uint32 { return p.rxOverrun.Load() } -func (p *fakeSerialPort) RXBreakCount() uint32 { return p.rxBreak.Load() } -func (p *fakeSerialPort) RXParityCount() uint32 { return p.rxParity.Load() } -func (p *fakeSerialPort) RXFramingCount() uint32 { return p.rxFraming.Load() } - -func (p *fakeSerialPort) TryRead(dst []byte) int { - p.readCalls.Add(1) - recordMax(&p.maxReadLen, len(dst)) - if !p.continuousRX.Load() || len(dst) == 0 { - return 0 - } - for i := range dst { - dst[i] = 'r' - } - p.signalReadable() - return len(dst) -} - -func (p *fakeSerialPort) TryWrite(src []byte) int { - p.writeCalls.Add(1) - recordMax(&p.maxWriteLen, len(src)) - if len(src) == 0 { - return 0 - } - p.mu.Lock() - p.written = append(p.written, src...) - p.mu.Unlock() - p.signalWritable() - return len(src) -} - -func (p *fakeSerialPort) Readable() <-chan struct{} { return p.readable } -func (p *fakeSerialPort) Writable() <-chan struct{} { return p.writable } -func (p *fakeSerialPort) Flush() error { return nil } - -func (p *fakeSerialPort) signalReadable() { - select { - case p.readable <- struct{}{}: - default: - } -} - -func (p *fakeSerialPort) signalWritable() { - select { - case p.writable <- struct{}{}: - default: - } -} - -func (p *fakeSerialPort) writtenBytes() []byte { - p.mu.Lock() - defer p.mu.Unlock() - out := make([]byte, len(p.written)) - copy(out, p.written) - return out -} - -func recordMax(max *atomic.Int32, n int) { - for { - cur := max.Load() - if int32(n) <= cur { - return - } - if max.CompareAndSwap(cur, int32(n)) { - return - } - } -} - -func newTestDevice(port *fakeSerialPort) *Device { - return &Device{ - id: "uart1_raw", - a: core.CapAddr{Domain: "io", Kind: types.KindSerial, Name: "uart1"}, - port: port, - } -} - -func drainRXUntil(ctx context.Context, s *session) { - var buf [128]byte - for { - if s.rxRing.TryReadInto(buf[:]) > 0 { - continue - } - select { - case <-ctx.Done(): - return - case <-s.rxRing.Readable(): - } - } -} - -func waitUntil(t *testing.T, timeout time.Duration, pred func() bool) { - t.Helper() - deadline := time.Now().Add(timeout) - for time.Now().Before(deadline) { - if pred() { - return - } - time.Sleep(time.Millisecond) - } - t.Fatal("condition was not met before timeout") -} - -func TestDriverPressureLogIncludesPumpEvidence(t *testing.T) { - port := newFakeSerialPort() - port.rxBuffered.Store(128) - port.rxBufferCap.Store(128) - port.rxDrops.Store(3) - port.rxOverrun.Store(2) - port.rxFraming.Store(1) - dev := newTestDevice(port) - dev.startSession(512, 512) - defer dev.stopSession() - - s := dev.sess - s.lastRXPumpAt = time.Now().Add(-50 * time.Millisecond) - s.lastRXPumpMoved = 0 - s.lastRXPumpDurMS = 0 - s.lastRXPumpGapMS = 50 - - var lines []string - restore := otadiag.SetSinkForTest(func(line string) { - lines = append(lines, line) - }) - defer restore() - - dev.logDriverPressure(s, true) - - joined := strings.Join(lines, "\n") - for _, want := range []string{ - "[serial-raw]", - "ev rx_driver_pressure", - "uart uart1", - "driver_used 128", - "driver_cap 128", - "ring_space 512", - "since_rx_pump_ms", - "last_pump_gap_ms 50", - "rx_drops 3", - "rx_overrun 2", - "rx_framing 1", - "ev rx_pump_gap", - } { - if !strings.Contains(joined, want) { - t.Fatalf("pressure log missing %q:\n%s", want, joined) - } - } -} - -func TestReactorServicesTXWhileRXIsContinuous(t *testing.T) { - port := newFakeSerialPort() - port.continuousRX.Store(true) - dev := newTestDevice(port) - dev.startSession(512, 512) - - drainCtx, stopDrain := context.WithCancel(context.Background()) - defer stopDrain() - go drainRXUntil(drainCtx, dev.sess) - - payload := []byte("tx while rx is busy") - if n := dev.sess.txRing.TryWriteFrom(payload); n != len(payload) { - t.Fatalf("failed to seed tx ring: wrote %d/%d", n, len(payload)) - } - - waitUntil(t, 100*time.Millisecond, func() bool { - return port.writeCalls.Load() > 0 - }) - - if got := string(port.writtenBytes()); got != string(payload) { - t.Fatalf("written payload mismatch: got %q want %q", got, payload) - } - if max := port.maxReadLen.Load(); max > serialRawPumpRXBudget { - t.Fatalf("TryRead span exceeded budget: got %d want <= %d", max, serialRawPumpRXBudget) - } - if max := port.maxWriteLen.Load(); max > serialRawPumpTXBudget { - t.Fatalf("TryWrite span exceeded budget: got %d want <= %d", max, serialRawPumpTXBudget) - } - - dev.stopSession() -} - -func TestStopSessionReturnsUnderContinuousRX(t *testing.T) { - port := newFakeSerialPort() - port.continuousRX.Store(true) - dev := newTestDevice(port) - dev.startSession(512, 512) - - drainCtx, stopDrain := context.WithCancel(context.Background()) - defer stopDrain() - go drainRXUntil(drainCtx, dev.sess) - - done := make(chan struct{}) - go func() { - dev.stopSession() - close(done) - }() - - select { - case <-done: - case <-time.After(100 * time.Millisecond): - t.Fatal("stopSession did not return under continuous RX") - } -} - -func TestStopSessionReturnsWhenIdle(t *testing.T) { - dev := newTestDevice(newFakeSerialPort()) - dev.startSession(512, 512) - - done := make(chan struct{}) - go func() { - dev.stopSession() - close(done) - }() - - select { - case <-done: - case <-time.After(100 * time.Millisecond): - t.Fatal("stopSession did not return while idle") - } -} diff --git a/services/hal/internal/provider/rp2_resources.go b/services/hal/internal/provider/rp2_resources.go index 8967322..28e24e6 100644 --- a/services/hal/internal/provider/rp2_resources.go +++ b/services/hal/internal/provider/rp2_resources.go @@ -711,55 +711,6 @@ func (p *rp2SerialPort) TryRead(b []byte) int { return p.u.TryRead(b) } func (p *rp2SerialPort) TryWrite(b []byte) int { return p.u.TryWrite(b) } func (p *rp2SerialPort) Flush() error { return p.u.Flush() } -func (p *rp2SerialPort) RXBuffered() int { - if p.u == nil || p.u.Buffer == nil { - return -1 - } - return int(p.u.Buffer.Used()) -} - -func (p *rp2SerialPort) RXBufferCap() int { - if p.u == nil || p.u.Buffer == nil { - return -1 - } - return int(p.u.Buffer.Size()) -} - -func (p *rp2SerialPort) RXDropCount() uint32 { - if p.u == nil { - return 0 - } - return p.u.RXDropCount() -} - -func (p *rp2SerialPort) RXOverrunCount() uint32 { - if p.u == nil { - return 0 - } - return p.u.RXOverrunCount() -} - -func (p *rp2SerialPort) RXBreakCount() uint32 { - if p.u == nil { - return 0 - } - return p.u.RXBreakCount() -} - -func (p *rp2SerialPort) RXParityCount() uint32 { - if p.u == nil { - return 0 - } - return p.u.RXParityCount() -} - -func (p *rp2SerialPort) RXFramingCount() uint32 { - if p.u == nil { - return 0 - } - return p.u.RXFramingCount() -} - func (p *rp2SerialPort) SetBaudRate(br uint32) error { p.u.SetBaudRate(br); return nil } // Parity strings: "none","even","odd" diff --git a/services/hal/internal/provider/setup_none.go b/services/hal/internal/provider/setup_none.go index 9bfa5b8..2863103 100644 --- a/services/hal/internal/provider/setup_none.go +++ b/services/hal/internal/provider/setup_none.go @@ -1,4 +1,4 @@ -//go:build (rp2040 || rp2350) && !(pico_rich_dev || pico_bb_proto_1) +//go:build !((rp2040 || rp2350) && (pico_rich_dev || pico_bb_proto_1)) package provider diff --git a/services/hal/internal/provider/setups/pico_bb_proto_1.go b/services/hal/internal/provider/setups/pico_bb_proto_1.go index ae3d94d..58a8101 100644 --- a/services/hal/internal/provider/setups/pico_bb_proto_1.go +++ b/services/hal/internal/provider/setups/pico_bb_proto_1.go @@ -58,8 +58,8 @@ var SelectedSetup = types.HALConfig{ Domain: "io", Name: "uart1", Baud: 115_200, - RXSize: 256, - TXSize: 2048, + RXSize: 32, + TXSize: 512, }}, {ID: "charger0", Type: "ltc4015", Params: ltc4015dev.Params{ diff --git a/services/otadiag/otadiag.go b/services/otadiag/otadiag.go index b31999b..f156484 100644 --- a/services/otadiag/otadiag.go +++ b/services/otadiag/otadiag.go @@ -16,9 +16,10 @@ type Field struct { } var ( - startedAt = time.Now() - nextSeq atomic.Uint64 - verbose atomic.Bool + startedAt = time.Now() + nextSeq atomic.Uint64 + verbose atomic.Bool + heartbeatDeadlineMS atomic.Int64 sinkMu sync.Mutex sink func(string) @@ -104,6 +105,21 @@ func SetUpdaterSnapshot(s StageSnapshot) { windowMu.Unlock() } +func SetHeartbeatDeadline(d time.Duration) { + if d <= 0 { + d = 45 * time.Second + } + heartbeatDeadlineMS.Store(d.Milliseconds()) +} + +func currentHeartbeatDeadline() time.Duration { + ms := heartbeatDeadlineMS.Load() + if ms <= 0 { + return 45 * time.Second + } + return time.Duration(ms) * time.Millisecond +} + func StartUpdateWindow(reason, xferID string) { if xferID == "" { xferID = XferNone @@ -166,7 +182,7 @@ func WindowActive() bool { func heartbeatLoop(stop <-chan struct{}) { ticker := time.NewTicker(time.Second) defer ticker.Stop() - deadline := time.NewTimer(45 * time.Second) + deadline := time.NewTimer(currentHeartbeatDeadline()) defer deadline.Stop() for { @@ -217,17 +233,18 @@ func allowEvent(prefix, event string, fields []Field) bool { if verbose.Load() { return true } + // Normal firmware builds keep Fabric/OTA observability as retained counters + // and state, not as per-frame/per-chunk log lines. The sink used by tests can + // still opt into the detailed stream through SetSinkForTest/verbose. switch prefix { - case "[serial-raw]", "[fabric-rx]", "[fabric-rpc]", "[fabric-handshake]": - return true case "[mcu-ota]": return event == "heartbeat_start" || event == "heartbeat_stop" - case "[fabric-xfer]": - return allowFabricXferEvent(event, fields) - case "[updater-stream]": - return allowUpdaterStreamEvent(event) + case "[serial-raw]", "[fabric-rx]", "[fabric-rpc]", "[fabric-handshake]", "[fabric-xfer]", "[updater-stream]": + return strings.HasSuffix(event, "_error") || + strings.Contains(event, "reject") || + strings.Contains(event, "abort") default: - return true + return false } } diff --git a/services/otadiag/otadiag_test.go b/services/otadiag/otadiag_test.go index 590d93f..5b44c5f 100644 --- a/services/otadiag/otadiag_test.go +++ b/services/otadiag/otadiag_test.go @@ -29,27 +29,22 @@ func TestDefaultFilterKeepsActionableEvents(t *testing.T) { lines, restore := captureDefaultFilteredEvents() defer restore() - Event("[serial-raw]", "rx_driver_pressure", XferNone, KV("uart", "uart0")) - Event("[fabric-rx]", "read_line", XferNone, KV("type", "ping")) - Event("[fabric-rpc]", "sent", XferNone, KV("call_id", "call-1")) + Event("[serial-raw]", "rx_ring_error", XferNone, KV("uart", "uart0")) + Event("[fabric-rx]", "read_line_error", XferNone, KV("reason", "line_too_long")) + Event("[fabric-rpc]", "call_reject", XferNone, KV("call_id", "call-1")) Event("[mcu-ota]", "heartbeat_start", "xfer-1", KV("reason", "prepare")) Event("[mcu-ota]", "heartbeat_stop", "xfer-1", KV("reason", "done")) - Event("[fabric-xfer]", "begin_rx", "xfer-1", KV("target", "updater/main")) - Event("[fabric-xfer]", "ready_tx", "xfer-1", KV("ok", true)) - Event("[fabric-xfer]", "need_tx", "xfer-1", KV("next", 0), KV("ok", true)) - Event("[fabric-xfer]", "chunk_digest_done", "xfer-1", KV("ok", false), KV("reason", "chunk_digest_mismatch")) + Event("[fabric-xfer]", "xfer_abort", "xfer-1", KV("reason", "cancelled")) Event("[fabric-xfer]", "sink_write_error", "xfer-1", KV("reason", "write_boom")) - Event("[updater-stream]", "prepare_rx", XferNone, KV("job_id", "job-1")) - Event("[updater-stream]", "flash_erase_start", "xfer-1", KV("offset", 0)) + Event("[updater-stream]", "prepare_reject", XferNone, KV("reason", "busy")) Event("[updater-stream]", "image_signature_verify_error", "xfer-1", KV("reason", "bad_signature")) got := strings.Join(*lines, "\n") for _, want := range []string{ "[serial-raw]", "[fabric-rx]", "[fabric-rpc]", "ev heartbeat_start", "ev heartbeat_stop", - "ev begin_rx", "ev ready_tx", "ev need_tx", - "ev chunk_digest_done", "ev sink_write_error", - "ev prepare_rx", "ev flash_erase_start", "ev image_signature_verify_error", + "ev xfer_abort", "ev sink_write_error", + "ev prepare_reject", "ev image_signature_verify_error", } { if !strings.Contains(got, want) { t.Fatalf("default filter output missing %q:\n%s", want, got) diff --git a/services/reactor/build_policy_apply_test.go b/services/reactor/build_policy_apply_test.go new file mode 100644 index 0000000..8b86e1f --- /dev/null +++ b/services/reactor/build_policy_apply_test.go @@ -0,0 +1,17 @@ +//go:build !qa_reactor && !fabric_uart_hwtest && fabric_stage_enabled && fabric_apply_enabled && !fabric_uart_selftest + +package reactor + +import "testing" + +func TestBuildPolicyApply(t *testing.T) { + if got := fabricTransferMode(); got != "stage-controller:flash-stage" { + t.Fatalf("fabricTransferMode() = %q", got) + } + if got := updaterRuntimeMode(); got != "production-applier:commit-reboots" { + t.Fatalf("updaterRuntimeMode() = %q", got) + } + if !useHardwareFabricUART() { + t.Fatalf("fabric_apply_enabled production build should use hardware Fabric UART") + } +} diff --git a/services/reactor/build_policy_apply_without_stage_test.go b/services/reactor/build_policy_apply_without_stage_test.go new file mode 100644 index 0000000..bd6fbaf --- /dev/null +++ b/services/reactor/build_policy_apply_without_stage_test.go @@ -0,0 +1,14 @@ +//go:build !qa_reactor && !fabric_stage_enabled && fabric_apply_enabled && !fabric_uart_hwtest && !fabric_uart_selftest + +package reactor + +import "testing" + +func TestBuildPolicyApplyWithoutStageIsSafe(t *testing.T) { + if got := fabricTransferMode(); got != "stage-disabled" { + t.Fatalf("fabricTransferMode() = %q", got) + } + if got := updaterRuntimeMode(); got != "safe-defaults:apply-disabled" { + t.Fatalf("updaterRuntimeMode() = %q", got) + } +} diff --git a/services/reactor/build_policy_default_test.go b/services/reactor/build_policy_default_test.go new file mode 100644 index 0000000..353dc44 --- /dev/null +++ b/services/reactor/build_policy_default_test.go @@ -0,0 +1,17 @@ +//go:build !qa_reactor && !fabric_uart_hwtest && !fabric_stage_enabled && !fabric_apply_enabled && !fabric_uart_selftest + +package reactor + +import "testing" + +func TestBuildPolicyDefault(t *testing.T) { + if got := fabricTransferMode(); got != "stage-disabled" { + t.Fatalf("fabricTransferMode() = %q", got) + } + if got := updaterRuntimeMode(); got != "safe-defaults:apply-disabled" { + t.Fatalf("updaterRuntimeMode() = %q", got) + } + if !useHardwareFabricUART() { + t.Fatalf("default build should use hardware Fabric UART") + } +} diff --git a/services/reactor/build_policy_flash_stage_test.go b/services/reactor/build_policy_flash_stage_test.go new file mode 100644 index 0000000..9bd0008 --- /dev/null +++ b/services/reactor/build_policy_flash_stage_test.go @@ -0,0 +1,17 @@ +//go:build !qa_reactor && !fabric_uart_hwtest && fabric_stage_enabled && !fabric_apply_enabled && !fabric_uart_selftest + +package reactor + +import "testing" + +func TestBuildPolicyFlashStage(t *testing.T) { + if got := fabricTransferMode(); got != "stage-controller:flash-stage" { + t.Fatalf("fabricTransferMode() = %q", got) + } + if got := updaterRuntimeMode(); got != "safe-defaults:apply-disabled" { + t.Fatalf("updaterRuntimeMode() = %q", got) + } + if !useHardwareFabricUART() { + t.Fatalf("fabric_stage_enabled should use hardware Fabric UART") + } +} diff --git a/services/reactor/build_policy_selftest_test.go b/services/reactor/build_policy_selftest_test.go new file mode 100644 index 0000000..049add1 --- /dev/null +++ b/services/reactor/build_policy_selftest_test.go @@ -0,0 +1,17 @@ +//go:build !qa_reactor && fabric_uart_hwtest && fabric_uart_selftest + +package reactor + +import "testing" + +func TestBuildPolicyUARTSelfTest(t *testing.T) { + if got := fabricTransferMode(); got != "stage-controller:hwtest" { + t.Fatalf("fabricTransferMode() = %q", got) + } + if got := updaterRuntimeMode(); got != "safe-defaults:apply-disabled" { + t.Fatalf("updaterRuntimeMode() = %q", got) + } + if useHardwareFabricUART() { + t.Fatalf("fabric_uart_selftest should disable the hardware Fabric UART") + } +} diff --git a/services/reactor/build_policy_uart_hwtest_test.go b/services/reactor/build_policy_uart_hwtest_test.go new file mode 100644 index 0000000..b2ded8b --- /dev/null +++ b/services/reactor/build_policy_uart_hwtest_test.go @@ -0,0 +1,17 @@ +//go:build !qa_reactor && fabric_uart_hwtest && !fabric_uart_selftest + +package reactor + +import "testing" + +func TestBuildPolicyUARTHWTest(t *testing.T) { + if got := fabricTransferMode(); got != "stage-controller:hwtest" { + t.Fatalf("fabricTransferMode() = %q", got) + } + if got := updaterRuntimeMode(); got != "safe-defaults:apply-disabled" { + t.Fatalf("updaterRuntimeMode() = %q", got) + } + if !useHardwareFabricUART() { + t.Fatalf("fabric_uart_hwtest should use hardware Fabric UART unless fabric_uart_selftest is also set") + } +} diff --git a/services/reactor/children.go b/services/reactor/children.go new file mode 100644 index 0000000..018e0ce --- /dev/null +++ b/services/reactor/children.go @@ -0,0 +1,163 @@ +//go:build !qa_reactor + +package reactor + +import ( + "context" + + "devicecode-go/services/telemetry" + "devicecode-go/services/updater" +) + +// FirmwareVersion/FirmwareBuild/FirmwareImageID are the stamps the updater +// publishes via state/self/software. They are development sentinels for this +// original-reactor branch; build tooling can override them later by adding a +// same-package init file when the release wiring is introduced. +var ( + FirmwareVersion = "0.0.0-dev" + FirmwareBuild = "local" + FirmwareImageID = "img-dev" +) + +func firmwareIdentity() updater.Identity { + return updater.Identity{ + Version: FirmwareVersion, + Build: FirmwareBuild, + ImageID: FirmwareImageID, + } +} + +// childState describes lifecycle owned by the top-level Reactor. It is +// intentionally small: children own their internal state machines; the Reactor +// only starts them, observes unexpected exits, and stops them with its own +// context. +type childState uint8 + +const ( + childStopped childState = iota + childRunning + childFailed +) + +type childExit struct { + name string + expected bool +} + +type childRuntime struct { + name string + run func(context.Context) + cancel context.CancelFunc + state childState +} + +type childSupervisor struct { + children []childRuntime + done chan childExit +} + +func (s *childSupervisor) Add(name string, run func(context.Context)) { + if name == "" || run == nil { + return + } + s.children = append(s.children, childRuntime{name: name, run: run, state: childStopped}) +} + +func (s *childSupervisor) StartAll(ctx context.Context) { + if s.done == nil { + s.done = make(chan childExit, 4) + } + for i := range s.children { + if s.children[i].state == childRunning { + continue + } + childCtx, cancel := context.WithCancel(ctx) + s.children[i].cancel = cancel + s.children[i].state = childRunning + name := s.children[i].name + run := s.children[i].run + done := s.done + go func() { + run(childCtx) + expected := childCtx.Err() != nil + select { + case done <- childExit{name: name, expected: expected}: + default: + } + }() + log.Println("[svc] ", name, " started") + } +} + +func (s *childSupervisor) Done() <-chan childExit { + if s == nil || s.done == nil { + return nil + } + return s.done +} + +func (s *childSupervisor) HandleExit(ev childExit) { + if s == nil || ev.name == "" { + return + } + for i := range s.children { + if s.children[i].name != ev.name { + continue + } + if ev.expected { + s.children[i].state = childStopped + log.Println("[svc] ", ev.name, " stopped") + } else { + s.children[i].state = childFailed + log.Println("[svc] ", ev.name, " exited unexpectedly") + } + return + } +} + +func (s *childSupervisor) StopAll() { + if s == nil { + return + } + for i := range s.children { + if s.children[i].cancel != nil { + s.children[i].cancel() + } + } +} + +func (r *Reactor) startCoreChildren(ctx context.Context) { + if r == nil || r.uiConn == nil { + return + } + + // Updater publishes retained state/self/{software,updater,health} facts and + // binds the local updater RPC topics. The default firmware build still keeps + // Fabric staging safe-disabled at the Reactor boundary; fabric_uart_hwtest or + // fabric_stage_enabled explicitly opt into using this service as Fabric + // StageController. + updater.GenerateBootID() + updaterConn := r.uiConn.NewChildConnection("updater") + if updaterConn != nil { + updaterSvc := updater.New(updaterServiceOptions(updaterConn)) + log.Println("[updater] policy ", updaterRuntimeMode()) + r.updaterSvc = updaterSvc + r.children.Add("updater", updaterSvc.Run) + } + + telemetryConn := r.uiConn.NewChildConnection("telemetry") + if telemetryConn != nil { + telemetrySvc := telemetry.New(telemetryConn) + r.children.Add("telemetry", telemetrySvc.Run) + } + r.addFabricSelfTestChild() + r.children.StartAll(ctx) +} + +func (r *Reactor) stopCoreChildren() { + if r == nil { + return + } + r.children.StopAll() + r.updaterSvc = nil +} diff --git a/services/reactor/fabric_link.go b/services/reactor/fabric_link.go new file mode 100644 index 0000000..1a0d25f --- /dev/null +++ b/services/reactor/fabric_link.go @@ -0,0 +1,96 @@ +//go:build !qa_reactor + +package reactor + +import ( + "context" + "time" + + "devicecode-go/services/fabric" + "devicecode-go/types" + "devicecode-go/x/shmring" +) + +const ( + fabricUART = "uart1" + fabricStopWaitTimeout = 500 * time.Millisecond +) + +// fabricBuffers is allocated once at package scope so the UART/Fabric hot path +// does not construct line or transfer-sized buffers on demand. It is shared by +// at most one active Fabric session; the Reactor tears any old session down +// before starting a replacement. +var fabricBuffers fabric.FabricBuffers + +func waitFabricDone(done <-chan struct{}, timeout time.Duration) bool { + if done == nil { + return true + } + timer := time.NewTimer(timeout) + defer timer.Stop() + select { + case <-done: + return true + case <-timer.C: + return false + } +} + +func (r *Reactor) startPassiveFabric(ctx context.Context, ev types.SerialSessionOpened) { + if r == nil || r.uiConn == nil { + return + } + // Only one Fabric session may own the UART rings. A fresh HAL session_opened + // event replaces the previous session explicitly. + r.stopFabricLink() + + rx := shmring.Get(shmring.Handle(ev.RXHandle)) + tx := shmring.Get(shmring.Handle(ev.TXHandle)) + if rx == nil || tx == nil { + log.Println("[uart1] fabric session missing rings") + return + } + + tr := fabric.NewShmringTransportWithBuffers(rx, tx, &fabricBuffers) + fabricConn := r.uiConn.NewChildConnection("fabric") + if fabricConn == nil { + _ = tr.Close() + log.Println("[uart1] fabric session missing bus") + return + } + + stageController := r.fabricStageController() + transferMode := fabricTransferMode() + + fabricCtx, cancel := context.WithCancel(ctx) + done := make(chan struct{}) + r.fabricCancel = cancel + r.fabricDone = done + r.fabricSessionOpen = true + + log.Println("[uart1] fabric session opening node=mcu peer=bigbox-cm5 link=mcu-uart0 transfer=", transferMode) + go func() { + defer close(done) + defer tr.Close() + // The transfer policy is selected by build tag. The default firmware build + // uses a rejecting controller, so an unexpected xfer_begin cannot enter + // flash staging. fabric_uart_hwtest/fabric_stage_enabled explicitly opt in + // to the updater-owned stage controller. + fabric.RunWithOptions(fabricCtx, tr, fabricConn, "mcu", "bigbox-cm5", fabric.DefaultLinkConfig(), fabric.RunOptions{Buffers: &fabricBuffers, StageController: stageController}) + }() + log.Println("[uart1] fabric session opened node=mcu peer=bigbox-cm5 link=mcu-uart0 transfer=", transferMode) +} + +func (r *Reactor) stopFabricLink() { + if r == nil || r.fabricCancel == nil { + return + } + done := r.fabricDone + r.fabricCancel() + r.fabricCancel = nil + r.fabricDone = nil + r.fabricSessionOpen = false + if !waitFabricDone(done, fabricStopWaitTimeout) { + log.Println("[uart1] fabric session stop timed out") + } +} diff --git a/services/reactor/fabric_selftest_disabled.go b/services/reactor/fabric_selftest_disabled.go new file mode 100644 index 0000000..5163b6d --- /dev/null +++ b/services/reactor/fabric_selftest_disabled.go @@ -0,0 +1,5 @@ +//go:build !qa_reactor && (!fabric_uart_selftest || !fabric_uart_hwtest) + +package reactor + +func (r *Reactor) addFabricSelfTestChild() {} diff --git a/services/reactor/fabric_selftest_enabled.go b/services/reactor/fabric_selftest_enabled.go new file mode 100644 index 0000000..eacb26e --- /dev/null +++ b/services/reactor/fabric_selftest_enabled.go @@ -0,0 +1,36 @@ +//go:build !qa_reactor && fabric_uart_selftest && fabric_uart_hwtest + +package reactor + +import ( + "context" + "time" + + "devicecode-go/services/fabric" +) + +func (r *Reactor) addFabricSelfTestChild() { + if r == nil || r.uiConn == nil || r.updaterSvc == nil { + return + } + conn := r.uiConn.NewChildConnection("fabric-selftest") + if conn == nil { + return + } + r.children.Add("fabric-selftest", func(ctx context.Context) { + log.Println("[fabric-selftest] starting in-process UART cross-wire transfer") + res, err := fabric.RunUARTSelfTest(ctx, fabric.UARTSelfTestOptions{ + Conn: conn, + StageController: r.updaterSvc, + PayloadSize: 1024, + ChunkSize: 256, + Timeout: 10 * time.Second, + }) + if err != nil { + log.Println("[fabric-selftest] failed err=", err.Error()) + } else { + log.Println("[fabric-selftest] ok xfer=", res.XferID, " bytes=", int(res.PayloadSize), " chunk=", int(res.ChunkSize), " digest=", res.Digest) + } + <-ctx.Done() + }) +} diff --git a/services/reactor/fabric_stage_disabled.go b/services/reactor/fabric_stage_disabled.go new file mode 100644 index 0000000..185d81f --- /dev/null +++ b/services/reactor/fabric_stage_disabled.go @@ -0,0 +1,54 @@ +//go:build !qa_reactor && !fabric_uart_hwtest && !fabric_stage_enabled + +package reactor + +import ( + "errors" + + "devicecode-go/services/fabric" +) + +// rejectingFabricStageController is the default firmware transfer policy for +// this integration slice. It makes the Fabric transfer boundary explicit while +// guaranteeing that an unexpected xfer_begin cannot enter the TinyGo flash +// prestage path. Hardware cross-wire tests opt in to the updater-owned stage +// controller with the fabric_uart_hwtest build tag; production firmware can do +// the same later with fabric_stage_enabled once the flash path is ready. +type rejectingFabricStageController struct{} + +func fabricTransferMode() string { return "stage-disabled" } + +func (r *Reactor) fabricStageController() fabric.StageController { + return rejectingFabricStageController{} +} + +func (rejectingFabricStageController) BeginStreamedStage(xferID string, size uint32) (uint64, error) { + _ = xferID + _ = size + return 0, errors.New("stage_disabled") +} + +func (rejectingFabricStageController) WriteStreamedStage(xferID string, generation uint64, data []byte) error { + _ = xferID + _ = generation + _ = data + return errors.New("stage_disabled") +} + +func (rejectingFabricStageController) CommitStreamedStage(xferID string, generation uint64) (uint32, error) { + _ = xferID + _ = generation + return 0, errors.New("stage_disabled") +} + +func (rejectingFabricStageController) AbortStreamedStage(xferID string, generation uint64, reason string) { + _ = xferID + _ = generation + _ = reason +} + +func (rejectingFabricStageController) CancelStreamedStage(xferID string, generation uint64, reason string) { + _ = xferID + _ = generation + _ = reason +} diff --git a/services/reactor/fabric_stage_flash.go b/services/reactor/fabric_stage_flash.go new file mode 100644 index 0000000..5faba80 --- /dev/null +++ b/services/reactor/fabric_stage_flash.go @@ -0,0 +1,14 @@ +//go:build !qa_reactor && !fabric_uart_hwtest && fabric_stage_enabled + +package reactor + +import "devicecode-go/services/fabric" + +func fabricTransferMode() string { return "stage-controller:flash-stage" } + +func (r *Reactor) fabricStageController() fabric.StageController { + if r == nil { + return nil + } + return r.updaterSvc +} diff --git a/services/reactor/fabric_stage_hwtest.go b/services/reactor/fabric_stage_hwtest.go new file mode 100644 index 0000000..315e085 --- /dev/null +++ b/services/reactor/fabric_stage_hwtest.go @@ -0,0 +1,14 @@ +//go:build !qa_reactor && fabric_uart_hwtest + +package reactor + +import "devicecode-go/services/fabric" + +func fabricTransferMode() string { return "stage-controller:hwtest" } + +func (r *Reactor) fabricStageController() fabric.StageController { + if r == nil { + return nil + } + return r.updaterSvc +} diff --git a/services/reactor/fabric_uart_policy_default.go b/services/reactor/fabric_uart_policy_default.go new file mode 100644 index 0000000..590c6fb --- /dev/null +++ b/services/reactor/fabric_uart_policy_default.go @@ -0,0 +1,5 @@ +//go:build !qa_reactor && !fabric_uart_selftest + +package reactor + +func useHardwareFabricUART() bool { return true } diff --git a/services/reactor/fabric_uart_policy_selftest.go b/services/reactor/fabric_uart_policy_selftest.go new file mode 100644 index 0000000..f7906c3 --- /dev/null +++ b/services/reactor/fabric_uart_policy_selftest.go @@ -0,0 +1,5 @@ +//go:build !qa_reactor && fabric_uart_selftest + +package reactor + +func useHardwareFabricUART() bool { return false } diff --git a/services/reactor/qa_reactor.go b/services/reactor/qa_reactor.go index 5b4770c..813779f 100644 --- a/services/reactor/qa_reactor.go +++ b/services/reactor/qa_reactor.go @@ -128,11 +128,11 @@ const ( ) type Reactor struct { - bus *bus.Bus uiConn *bus.Connection // UART jsonOut *shmring.Ring // telemetry (JSON UART TX) + // Logger UART1 already handled by global logger (see SetUART1) // inputs (latest) vin_mV, vbat_mV int32 @@ -165,26 +165,15 @@ type Reactor struct { // telemetry drop counters (bytes) droppedUART0Bytes int - bootBuyRC int32 } -type Options struct { - BootBuyRC int32 -} - -func NewReactor(b *bus.Bus, uiConn *bus.Connection) *Reactor { - return NewReactorWithOptions(b, uiConn, Options{}) -} - -func NewReactorWithOptions(b *bus.Bus, uiConn *bus.Connection, opts Options) *Reactor { +func NewReactor(uiConn *bus.Connection) *Reactor { return &Reactor{ - bus: b, - uiConn: uiConn, - levelUp: true, - state: stateOff, - now: time.Now(), - bootBuyRC: opts.BootBuyRC, - ledTick: 0, + uiConn: uiConn, + levelUp: true, + state: stateOff, + now: time.Now(), + ledTick: 0, } } diff --git a/services/reactor/reactor.go b/services/reactor/reactor.go index 1cb0b3a..7d9bdb1 100644 --- a/services/reactor/reactor.go +++ b/services/reactor/reactor.go @@ -8,8 +8,6 @@ import ( "time" "devicecode-go/bus" - "devicecode-go/services/fabric" - "devicecode-go/services/telemetry" "devicecode-go/services/updater" "devicecode-go/types" "devicecode-go/utilities" @@ -17,78 +15,6 @@ import ( "devicecode-go/x/strconvx" ) -// FirmwareVersion/FirmwareBuild/FirmwareImageID are the stamps the updater -// publishes via state/self/software. main may override them before the reactor -// starts; defaults are development sentinels. -var ( - FirmwareVersion = "0.0.0-dev" - FirmwareBuild = "local" - FirmwareImageID = "img-dev" -) - -func firmwareIdentity() updater.Identity { - return updater.Identity{ - Version: FirmwareVersion, - Build: FirmwareBuild, - ImageID: FirmwareImageID, - } -} - -const ( - fabricWaitLogInterval = 2 * time.Second - fabricStopWaitTimeout = 500 * time.Millisecond -) - -func waitFabricDone(done <-chan struct{}, timeout time.Duration) bool { - if done == nil { - return true - } - timer := time.NewTimer(timeout) - defer timer.Stop() - select { - case <-done: - return true - case <-timer.C: - return false - } -} - -func waitForUpdaterCriticalFacts(ctx context.Context, conn *bus.Connection) bool { - if conn == nil { - return false - } - swSub := conn.Subscribe(updater.TopicSoftwareFact) - defer conn.Unsubscribe(swSub) - upSub := conn.Subscribe(updater.TopicUpdaterFact) - defer conn.Unsubscribe(upSub) - healthSub := conn.Subscribe(updater.TopicHealthFact) - defer conn.Unsubscribe(healthSub) - - softwareReady := false - updaterReady := false - healthReady := false - - for !(softwareReady && updaterReady && healthReady) { - select { - case <-ctx.Done(): - return false - case msg, ok := <-swSub.Channel(): - if ok && msg != nil && msg.Payload != nil { - softwareReady = true - } - case msg, ok := <-upSub.Channel(): - if ok && msg != nil && msg.Payload != nil { - updaterReady = true - } - case msg, ok := <-healthSub.Channel(): - if ok && msg != nil && msg.Payload != nil { - healthReady = true - } - } - } - return true -} - // ----------------------------------------------------------------------------- // Thresholds & timing // ----------------------------------------------------------------------------- @@ -171,6 +97,13 @@ func tSessClosed(name string) bus.Topic { return bus.T("hal", "cap", "io", "serial", name, "event", "session_closed") } +func subscriptionChannel(sub *bus.Subscription) <-chan *bus.Message { + if sub == nil { + return nil + } + return sub.Channel() +} + // ----------------------------------------------------------------------------- // Rail order (pre-gap semantics) // ----------------------------------------------------------------------------- @@ -203,9 +136,12 @@ const ( ) type Reactor struct { - bus *bus.Bus uiConn *bus.Connection + // UART + jsonOut *shmring.Ring // telemetry (JSON UART TX) + // Logger UART1 already handled by global logger (see SetUART1) + // inputs (latest) vin_mV, vbat_mV int32 iin_mA, ibat_mA int32 @@ -233,30 +169,31 @@ type Reactor struct { ledTick int // throttles breathe commands // misc - now time.Time - bootBuyRC int32 + now time.Time - // updater service handle used by the post-hello_ack republish hook. - updater *updater.Service -} + // telemetry drop counters (bytes) + droppedUART0Bytes int -type Options struct { - BootBuyRC int32 -} + // supervised children. The Reactor owns only lifecycle; child + // services own their own event loops and models. + children childSupervisor + updaterSvc *updater.Service -func NewReactor(b *bus.Bus, uiConn *bus.Connection) *Reactor { - return NewReactorWithOptions(b, uiConn, Options{}) + // Fabric link lifecycle. Fabric owns its protocol reactor; this top-level + // Reactor only opens/closes the HAL UART session and cancels the active + // Fabric session when the HAL session is replaced or closed. + fabricCancel context.CancelFunc + fabricDone chan struct{} + fabricSessionOpen bool } -func NewReactorWithOptions(b *bus.Bus, uiConn *bus.Connection, opts Options) *Reactor { +func NewReactor(uiConn *bus.Connection) *Reactor { return &Reactor{ - bus: b, - uiConn: uiConn, - levelUp: true, - state: stateOff, - now: time.Now(), - bootBuyRC: opts.BootBuyRC, - ledTick: 0, + uiConn: uiConn, + levelUp: true, + state: stateOff, + now: time.Now(), + ledTick: 0, } } @@ -456,23 +393,97 @@ func (r *Reactor) OnCharger(v types.ChargerValue) { r.vin_mV = v.VIN_mV r.iin_mA = v.IIn_mA r.tsVIN = r.now + + // JSON: {"power/charger/internal/vin":..,"vsys":..,"iin":..} + if r.jsonOut != nil { + var w utilities.JSONWriter + w.Write = r.jsonWrite + w.Begin() + w.KvInt("power/charger/internal/vin", int(v.VIN_mV)) + w.KvInt("power/charger/internal/vsys", int(v.VSYS_mV)) + w.KvInt("power/charger/internal/iin", int(v.IIn_mA)) + // Full bitfield maps (0/1) for LOCF pipelines + { + it := types.NewBitIter(types.SystemStatus(v.Sys), types.SystemStatusTable[:]) + for { + bitName, set, ok := it.NextAny() + if !ok { + break + } + if set { + w.KvInt("power/charger/internal/system/"+bitName, 1) + } else { + w.KvInt("power/charger/internal/system/"+bitName, 0) + } + } + } + { + it := types.NewBitIter(types.ChargeStatusBits(v.Status), types.ChargeStatusTable[:]) + for { + bitName, set, ok := it.NextAny() + if !ok { + break + } + if set { + w.KvInt("power/charger/internal/status/"+bitName, 1) + } else { + w.KvInt("power/charger/internal/status/"+bitName, 0) + } + } + } + { + it := types.NewBitIter(types.ChargerStateBits(v.State), types.ChargerStateTable[:]) + for { + bitName, set, ok := it.NextAny() + if !ok { + break + } + if set { + w.KvInt("power/charger/internal/state/"+bitName, 1) + } else { + w.KvInt("power/charger/internal/state/"+bitName, 0) + } + } + } + w.End() + } } func (r *Reactor) OnBattery(v types.BatteryValue) { r.vbat_mV = v.PackMilliV r.ibat_mA = v.IBatMilliA r.tsVBAT = r.now + + // JSON: {"power/battery/internal/vbat":..,"ibat":..} + if r.jsonOut != nil { + var w utilities.JSONWriter + w.Write = r.jsonWrite + w.Begin() + w.KvInt("power/battery/internal/vbat", int(v.PackMilliV)) + w.KvInt("power/battery/internal/ibat", int(v.IBatMilliA)) + w.KvInt("power/battery/internal/bsr", int(v.BSR_uOhmPerCell)) + w.End() + } } -func (r *Reactor) OnTempDeciC(label string, deci int, _ string) { +func (r *Reactor) OnTempDeciC(label string, deci int, jsonKey string) { log.Deci(label, deci) + if r.jsonOut != nil { + var w utilities.JSONWriter + w.Write = r.jsonWrite + w.Begin() + w.KvInt(jsonKey, deci) + w.End() + } } -// ---- memory snapshot (every ~3 s in main loop) ---- +// ---- memory snapshot telemetry (every ~2 s in main loop) ---- func (r *Reactor) emitMemSnapshot() { var ms runtime.MemStats + runtime.GC() runtime.ReadMemStats(&ms) + // log line log.Println( "[mem] ", "alloc:", int(ms.Alloc), " ", @@ -480,37 +491,20 @@ func (r *Reactor) emitMemSnapshot() { "mallocs:", int(ms.Mallocs), " ", "frees:", int(ms.Frees), ) + // JSON (minimal to keep overhead low) + if r.jsonOut != nil { + var w utilities.JSONWriter + w.Write = r.jsonWrite + w.Begin() + w.KvInt("sys/mem/alloc", int(ms.Alloc)) + w.End() + } } func (r *Reactor) Run(ctx context.Context) { - // Updater service: state machine + updater prepare/commit RPC - // RPC handlers + updater/main staging + retained state/self/{software, - // updater, health} facts. Started early so the initial fact retains - // land before fabric establishes — that way the first hello_ack - // observer sees a populated retain store. - updaterConn := r.bus.NewConnection("updater") - identity := firmwareIdentity() - updaterSvc := updater.New(updater.Options{ - Conn: updaterConn, - Verifier: updater.SignedImageVerifier(), - Applier: updater.ProductionApplier(), - Identity: identity, - BootBuyRC: r.bootBuyRC, - }) - go updaterSvc.Run(ctx) - r.updater = updaterSvc - if !waitForUpdaterCriticalFacts(ctx, r.bus.NewConnection("updater-ready")) { - return - } - - // Telemetry service: subscribes to HAL value topics and republishes - // at state/self/* with integer engineering units; runs the charger - // alert FSM and emits event/self/power/charger/alert on bit-set - // transitions. Started after the updater so the initial software/ - // updater retains land first. - telemetryConn := r.bus.NewConnection("telemetry") - telemetrySvc := telemetry.New(telemetryConn) - go telemetrySvc.Run(ctx) + r.startCoreChildren(ctx) + defer r.stopCoreChildren() + defer r.stopFabricLink() // Subscriptions (env + power) log.Println("[main] subscribing env + power …") @@ -521,34 +515,28 @@ func (r *Reactor) Run(ctx context.Context) { stSub := r.uiConn.Subscribe(stTopic) evSub := r.uiConn.Subscribe(evTopic) - // UART session for the CM5 Fabric link on proto_1 hardware. - const uartFabric = "uart1" - subSessOpenFabric := r.uiConn.Subscribe(tSessOpened(uartFabric)) - subSessClosedFabric := r.uiConn.Subscribe(tSessClosed(uartFabric)) - r.uiConn.Publish(r.uiConn.NewMessage(tSessOpen(uartFabric), nil, false)) - - // Retry back-off guards - var retryFabricAt time.Time - - // Fabric session lifecycle state - var fabricCancel context.CancelFunc - var fabricDone chan struct{} - var fabricSessionOpen bool - nextFabricWaitLog := time.Now() + // UART sessions. uart0 remains the original local JSON telemetry stream; + // uart1 is now reserved for the CM5 Fabric link. The logger still writes to + // the USB monitor, but no longer opens a competing uart1 log mirror. + const uartTele = "uart0" + subSessOpenTele := r.uiConn.Subscribe(tSessOpened(uartTele)) + subSessClosedTele := r.uiConn.Subscribe(tSessClosed(uartTele)) + var subSessOpenFabric *bus.Subscription + var subSessClosedFabric *bus.Subscription + if useHardwareFabricUART() { + subSessOpenFabric = r.uiConn.Subscribe(tSessOpened(fabricUART)) + subSessClosedFabric = r.uiConn.Subscribe(tSessClosed(fabricUART)) + } - stopFabricSession := func() { - if fabricCancel == nil { - return - } - done := fabricDone - fabricCancel() - fabricCancel = nil - fabricDone = nil - if !waitFabricDone(done, fabricStopWaitTimeout) { - log.Println("[uart1] fabric session stop timed out") - } + // Kick open requests (fire-and-forget; events carry handles). + r.uiConn.Publish(r.uiConn.NewMessage(tSessOpen(uartTele), nil, false)) + if useHardwareFabricUART() { + r.uiConn.Publish(r.uiConn.NewMessage(tSessOpen(fabricUART), nil, false)) } + // Retry back-off guards. + var retryTeleAt, retryFabricAt time.Time + // Supervisory ticker ticker := time.NewTicker(TICK) defer ticker.Stop() @@ -558,40 +546,32 @@ func (r *Reactor) Run(ctx context.Context) { for { select { // ---- UART session opened/closed ---- - case m := <-subSessOpenFabric.Channel(): + case m := <-subSessOpenTele.Channel(): + if ev, ok := m.Payload.(types.SerialSessionOpened); ok { + r.jsonOut = shmring.Get(shmring.Handle(ev.TXHandle)) + log.Println("[uart0] telemetry session opened") + } + case m := <-subscriptionChannel(subSessOpenFabric): if ev, ok := m.Payload.(types.SerialSessionOpened); ok { - // Tear down any previous fabric session before starting a new one. - stopFabricSession() - rx := shmring.Get(shmring.Handle(ev.RXHandle)) - tx := shmring.Get(shmring.Handle(ev.TXHandle)) - tr := fabric.NewShmringTransport(rx, tx) - fabricConn := r.bus.NewConnection("fabric") - fabricCtx, cancel := context.WithCancel(ctx) - done := make(chan struct{}) - fabricCancel = cancel - fabricDone = done - fabricSessionOpen = true - log.Println("[uart1] fabric session opening node=mcu peer=bigbox-cm5 link=mcu-uart0") - go func() { - defer close(done) - fabric.Run(fabricCtx, tr, fabricConn, "mcu", "bigbox-cm5", fabric.DefaultLinkConfig()) - }() - log.Println("[uart1] fabric session opened node=mcu peer=bigbox-cm5 link=mcu-uart0") + r.startPassiveFabric(ctx, ev) } - case <-subSessClosedFabric.Channel(): - // Ignore stale close events — the open handler already tears down - // the previous session before starting a new one. - if !fabricSessionOpen { - continue + case <-subSessClosedTele.Channel(): + r.jsonOut = nil + log.Println("[uart0] telemetry session closed") + // Auto-reopen with back-off + if time.Now().After(retryTeleAt) { + r.uiConn.Publish(r.uiConn.NewMessage(tSessOpen(uartTele), nil, false)) + retryTeleAt = time.Now().Add(2 * time.Second) } - stopFabricSession() - fabricSessionOpen = false - nextFabricWaitLog = time.Now() + case <-subscriptionChannel(subSessClosedFabric): + r.stopFabricLink() log.Println("[uart1] fabric session closed") + // Auto-reopen with back-off if time.Now().After(retryFabricAt) { - r.uiConn.Publish(r.uiConn.NewMessage(tSessOpen(uartFabric), nil, false)) + r.uiConn.Publish(r.uiConn.NewMessage(tSessOpen(fabricUART), nil, false)) retryFabricAt = time.Now().Add(2 * time.Second) } + // ---- Env prints ---- case m := <-tempSub.Channel(): if v, ok := m.Payload.(types.TemperatureValue); ok { @@ -607,6 +587,14 @@ func (r *Reactor) Run(ctx context.Context) { case m := <-humidSub.Channel(): if v, ok := m.Payload.(types.HumidityValue); ok { log.Hundredths("[value] env/humidity/core %RH=", int(v.RHx100)) + // JSON + if r.jsonOut != nil { + var w utilities.JSONWriter + w.Write = r.jsonWrite + w.Begin() + w.KvInt("env/humidity/core", int(v.RHx100)) + w.End() + } } // ---- Die Temp Backup ---- @@ -641,16 +629,29 @@ func (r *Reactor) Run(ctx context.Context) { case m := <-evSub.Channel(): printCapEvent(m) + // JSON: {"///event":""} + if r.jsonOut != nil { + dom, _ := m.Topic.At(2).(string) + kind, _ := m.Topic.At(3).(string) + name, _ := m.Topic.At(4).(string) + tag, _ := m.Topic.At(6).(string) + if dom != "" && kind != "" && name != "" && tag != "" { + var w utilities.JSONWriter + w.Write = r.jsonWrite + w.Begin() + w.KvStr(dom+"/"+kind+"/"+name+"/event", tag) + w.End() + } + } + + // ---- Child service lifecycle ---- + case ev := <-r.children.Done(): + r.children.HandleExit(ev) // ---- Supervisory tick ---- case <-ticker.C: r.now = time.Now() - if !fabricSessionOpen && !r.now.Before(nextFabricWaitLog) { - log.Println("[main] waiting for fabric connection start") - nextFabricWaitLog = r.now.Add(fabricWaitLogInterval) - } - // 1) Run FSM (includes symmetric reversal) r.stepFSM() @@ -671,6 +672,26 @@ func (r *Reactor) Run(ctx context.Context) { } } +// ----------------------------------------------------------------------------- +// Centralised UART write helpers (handle partial writes) +// ----------------------------------------------------------------------------- + +// uart0 (telemetry JSON) — returns bytes written; tracks dropped bytes on partial writes. +func (r *Reactor) jsonWrite(b []byte) int { + if r == nil || r.jsonOut == nil || len(b) == 0 { + return 0 + } + n := r.jsonOut.TryWriteFrom(b) + if n < len(b) { + r.droppedUART0Bytes += (len(b) - n) + // Rate-limited note + if r.droppedUART0Bytes == (len(b)-n) || (r.droppedUART0Bytes%1024) == 0 { + log.Println("[uart0] dropped bytes =", r.droppedUART0Bytes) + } + } + return n +} + // ----------------------------------------------------------------------------- // Printing helpers (via Logger) // ----------------------------------------------------------------------------- diff --git a/services/reactor/reactor_test.go b/services/reactor/reactor_test.go deleted file mode 100644 index 34a7389..0000000 --- a/services/reactor/reactor_test.go +++ /dev/null @@ -1,106 +0,0 @@ -//go:build !qa_reactor - -package reactor - -import ( - "context" - "testing" - "time" - - "devicecode-go/bus" - "devicecode-go/services/updater" -) - -func TestWaitFabricDoneNil(t *testing.T) { - if !waitFabricDone(nil, time.Millisecond) { - t.Fatal("nil fabric done channel should be treated as stopped") - } -} - -func TestWaitFabricDoneClosed(t *testing.T) { - done := make(chan struct{}) - close(done) - - if !waitFabricDone(done, 50*time.Millisecond) { - t.Fatal("closed fabric done channel should report stopped") - } -} - -func TestWaitFabricDoneTimeout(t *testing.T) { - done := make(chan struct{}) - start := time.Now() - - if waitFabricDone(done, 10*time.Millisecond) { - t.Fatal("open fabric done channel should time out") - } - if elapsed := time.Since(start); elapsed > 250*time.Millisecond { - t.Fatalf("timeout wait took too long: %s", elapsed) - } -} - -func TestNewReactorDefaultsBootBuyRCZero(t *testing.T) { - r := NewReactor(nil, nil) - if r.bootBuyRC != 0 { - t.Fatalf("bootBuyRC = %d, want 0", r.bootBuyRC) - } -} - -func TestNewReactorWithOptionsStoresBootBuyRC(t *testing.T) { - r := NewReactorWithOptions(nil, nil, Options{BootBuyRC: -42}) - if r.bootBuyRC != -42 { - t.Fatalf("bootBuyRC = %d, want -42", r.bootBuyRC) - } -} - -func TestWaitForUpdaterCriticalFactsRequiresAllThreeFacts(t *testing.T) { - b := bus.NewBus(16, "+", "#") - waitConn := b.NewConnection("wait") - pubConn := b.NewConnection("pub") - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - - done := make(chan bool, 1) - go func() { - done <- waitForUpdaterCriticalFacts(ctx, waitConn) - }() - - pubConn.Publish(pubConn.NewMessage( - updater.TopicSoftwareFact, - updater.SoftwareFact{ImageID: "img", Version: "1.0", BootID: "boot"}, - true, - )) - pubConn.Publish(pubConn.NewMessage( - updater.TopicUpdaterFact, - updater.UpdaterFact{State: updater.StateRunning}, - true, - )) - select { - case got := <-done: - t.Fatalf("wait returned %t before health fact", got) - case <-time.After(20 * time.Millisecond): - } - - pubConn.Publish(pubConn.NewMessage( - updater.TopicHealthFact, - updater.HealthFact{State: "ok"}, - true, - )) - select { - case got := <-done: - if !got { - t.Fatal("wait returned false after all critical facts") - } - case <-time.After(time.Second): - t.Fatal("timeout waiting for critical facts") - } -} - -func TestWaitForUpdaterCriticalFactsStopsOnContextCancel(t *testing.T) { - b := bus.NewBus(16, "+", "#") - ctx, cancel := context.WithCancel(context.Background()) - cancel() - - if waitForUpdaterCriticalFacts(ctx, b.NewConnection("wait")) { - t.Fatal("wait returned true after context cancellation") - } -} diff --git a/services/reactor/updater_policy_apply.go b/services/reactor/updater_policy_apply.go new file mode 100644 index 0000000..6f6d342 --- /dev/null +++ b/services/reactor/updater_policy_apply.go @@ -0,0 +1,18 @@ +//go:build !qa_reactor && fabric_stage_enabled && fabric_apply_enabled && !fabric_uart_hwtest && !fabric_uart_selftest + +package reactor + +import ( + "devicecode-go/bus" + "devicecode-go/services/updater" +) + +func updaterRuntimeMode() string { return "production-applier:commit-reboots" } + +func updaterServiceOptions(conn *bus.Connection) updater.Options { + return updater.Options{ + Conn: conn, + Identity: firmwareIdentity(), + Applier: updater.ProductionApplier(), + } +} diff --git a/services/reactor/updater_policy_default.go b/services/reactor/updater_policy_default.go new file mode 100644 index 0000000..82bdd72 --- /dev/null +++ b/services/reactor/updater_policy_default.go @@ -0,0 +1,17 @@ +//go:build !qa_reactor && (!fabric_stage_enabled || !fabric_apply_enabled || fabric_uart_hwtest || fabric_uart_selftest) + +package reactor + +import ( + "devicecode-go/bus" + "devicecode-go/services/updater" +) + +func updaterRuntimeMode() string { return "safe-defaults:apply-disabled" } + +func updaterServiceOptions(conn *bus.Connection) updater.Options { + return updater.Options{ + Conn: conn, + Identity: firmwareIdentity(), + } +} diff --git a/services/telemetry/telemetry.go b/services/telemetry/telemetry.go index 3c15b04..46fde2d 100644 --- a/services/telemetry/telemetry.go +++ b/services/telemetry/telemetry.go @@ -241,6 +241,14 @@ type linkObservation struct { LocalSID string } +// fabricLinkObserver is implemented by services/fabric's retained link-state +// payload. Keeping this as a tiny structural interface avoids JSON reflection +// in the common in-process TinyGo path while still tolerating map/JSON payloads +// in host-side tests. +type fabricLinkObserver interface { + FabricLinkObservation() (ready bool, peerSID string, localSID string) +} + func linkReadyEdgeReason(prev, cur linkObservation, hadPrev bool) string { if !cur.Ready { return "" @@ -278,6 +286,9 @@ func decodeLinkReady(msg *bus.Message) (string, linkObservation) { obs.PeerSID, _ = p["peer_sid"].(string) obs.LocalSID, _ = p["local_sid"].(string) return id, obs + case fabricLinkObserver: + obs.Ready, obs.PeerSID, obs.LocalSID = p.FabricLinkObservation() + return id, obs } // Probe via JSON for the typed-struct payload fabric publishes. b, err := json.Marshal(msg.Payload) diff --git a/services/updater/abupdate_diag_host.go b/services/updater/abupdate_diag_host.go index cfda0a4..7bc88b2 100644 --- a/services/updater/abupdate_diag_host.go +++ b/services/updater/abupdate_diag_host.go @@ -6,17 +6,32 @@ import "sync" var abupdateDiagMu sync.Mutex var abupdateDiagActive bool +var abupdateDiagXferID string +var abupdateDiagGeneration uint64 func installABUpdateDiagHook(xferID string, generation uint64) { - _, _ = xferID, generation abupdateDiagMu.Lock() abupdateDiagActive = true + abupdateDiagXferID = xferID + abupdateDiagGeneration = generation abupdateDiagMu.Unlock() } func clearABUpdateDiagHook() { abupdateDiagMu.Lock() abupdateDiagActive = false + abupdateDiagXferID = "" + abupdateDiagGeneration = 0 + abupdateDiagMu.Unlock() +} + +func clearABUpdateDiagHookFor(xferID string, generation uint64) { + abupdateDiagMu.Lock() + if abupdateDiagActive && abupdateDiagXferID == xferID && abupdateDiagGeneration == generation { + abupdateDiagActive = false + abupdateDiagXferID = "" + abupdateDiagGeneration = 0 + } abupdateDiagMu.Unlock() } diff --git a/services/updater/abupdate_diag_tinygo.go b/services/updater/abupdate_diag_tinygo.go index c789aee..0cd9a41 100644 --- a/services/updater/abupdate_diag_tinygo.go +++ b/services/updater/abupdate_diag_tinygo.go @@ -41,6 +41,13 @@ func clearABUpdateDiagHook() { abupdateDiagGeneration = 0 } +func clearABUpdateDiagHookFor(xferID string, generation uint64) { + if abupdateDiagXferID != xferID || abupdateDiagGeneration != generation { + return + } + clearABUpdateDiagHook() +} + func emitABUpdateDiag(event string, fields ...otadiag.Field) { var out [10]otadiag.Field n := 0 diff --git a/services/updater/applier_host.go b/services/updater/applier_host.go index 1633fd1..d50ba05 100644 --- a/services/updater/applier_host.go +++ b/services/updater/applier_host.go @@ -5,7 +5,7 @@ package updater // ProductionApplier returns the applier the reactor wires by default. // On host builds (tests, dev environments without a flash slot to // reboot into) this stays the safe-default RefusingApplier — commit -// returns apply_unavailable. Real reboot wiring lives in +// returns commit_failed. Real reboot wiring lives in // applier_tinygo.go. func ProductionApplier() Applier { return RefusingApplier() } diff --git a/services/updater/applier_tinygo.go b/services/updater/applier_tinygo.go index 8df4b86..b5b5336 100644 --- a/services/updater/applier_tinygo.go +++ b/services/updater/applier_tinygo.go @@ -24,7 +24,7 @@ const postCommitReplyFlushDelay = 750 * time.Millisecond func (abupdateApplier) CanApply(d StagedDescriptor) error { _ = d if !sharedUpdaterInit { - return errFromRC("apply_unavailable_uninited", 0) + return errors.New(ErrApplyUnavailable) } return nil } diff --git a/services/updater/prestage_host.go b/services/updater/prestage_host.go index 9f80db6..4d0acf1 100644 --- a/services/updater/prestage_host.go +++ b/services/updater/prestage_host.go @@ -47,7 +47,7 @@ func writeStreamedStage(xferID string, generation uint64, data []byte) error { return err } -func commitStreamedStage(xferID string, generation uint64) (streamedStage, error) { +func commitStreamedStage(svc *Service, xferID string, generation uint64) (streamedStage, error) { _, _ = xferID, generation f := hostStreamedStage.file if f == nil { @@ -57,7 +57,6 @@ func commitStreamedStage(xferID string, generation uint64) (streamedStage, error abortStreamedStage() return streamedStage{}, err } - svc := currentService() if svc == nil { abortStreamedStage() return streamedStage{}, errors.New("updater_not_running") diff --git a/services/updater/prestage_hwtest_tinygo.go b/services/updater/prestage_hwtest_tinygo.go new file mode 100644 index 0000000..4cbc761 --- /dev/null +++ b/services/updater/prestage_hwtest_tinygo.go @@ -0,0 +1,108 @@ +//go:build tinygo && rp2350 && fabric_uart_hwtest + +package updater + +import ( + "errors" + + "devicecode-go/x/xxhash" +) + +// Hardware UART/Fabric interconnection tests must exercise the Fabric receiver +// without writing the Pico2's inactive A/B slot. Under fabric_uart_hwtest the +// streamed stage is therefore a fixed-state digest/count sink. The production +// rp2350 prestage path remains in prestage_tinygo.go and is excluded by the +// build tag above. +var hwtestStreamedStage struct { + active bool + ready bool + xferID string + generation uint64 + declared uint32 + written uint32 + hasher xxhash.Hasher + desc streamedStage +} + +func startStreamedStage(xferID string, generation uint64, size uint32) error { + hwtestStreamedStage.active = true + hwtestStreamedStage.ready = false + hwtestStreamedStage.xferID = xferID + hwtestStreamedStage.generation = generation + hwtestStreamedStage.declared = size + hwtestStreamedStage.written = 0 + hwtestStreamedStage.hasher = *xxhash.New(0) + hwtestStreamedStage.desc = streamedStage{} + return nil +} + +func writeStreamedStage(xferID string, generation uint64, data []byte) error { + if !hwtestStreamedStage.active { + return errors.New("streamed_stage_not_started") + } + if hwtestStreamedStage.xferID != xferID || hwtestStreamedStage.generation != generation { + return errors.New("streamed_stage_generation_mismatch") + } + if len(data) == 0 { + return errors.New("empty_chunk") + } + hwtestStreamedStage.written += uint32(len(data)) + _, _ = hwtestStreamedStage.hasher.Write(data) + return nil +} + +func commitStreamedStage(svc *Service, xferID string, generation uint64) (streamedStage, error) { + _ = svc + if !hwtestStreamedStage.active { + return streamedStage{}, errors.New("streamed_stage_not_started") + } + if hwtestStreamedStage.xferID != xferID || hwtestStreamedStage.generation != generation { + return streamedStage{}, errors.New("streamed_stage_generation_mismatch") + } + if hwtestStreamedStage.written != hwtestStreamedStage.declared { + hwtestStreamedStage.active = false + return streamedStage{}, errors.New("streamed_stage_size_mismatch") + } + desc := streamedStage{ + Version: "uart-crosswire-test", + BuildID: "fabric-uart-crosswire", + ImageID: "hwtest-image", + Length: hwtestStreamedStage.written, + PayloadSHA256: "xxhash32:" + hwtestXXHashHex(hwtestStreamedStage.hasher.Sum32()), + } + hwtestStreamedStage.desc = desc + hwtestStreamedStage.ready = true + hwtestStreamedStage.active = false + return desc, nil +} + +func abortStreamedStage() { + hwtestStreamedStage.active = false + hwtestStreamedStage.ready = false + hwtestStreamedStage.desc = streamedStage{} +} + +func consumeStreamedStageResult() (streamedStage, bool) { + if !hwtestStreamedStage.ready { + return streamedStage{}, false + } + out := hwtestStreamedStage.desc + hwtestStreamedStage.ready = false + hwtestStreamedStage.desc = streamedStage{} + return out, true +} + +func discardStreamedStageResult() { + abortStreamedStage() + clearABUpdateDiagHook() +} + +func hwtestXXHashHex(v uint32) string { + const digits = "0123456789abcdef" + var buf [8]byte + for i := 7; i >= 0; i-- { + buf[i] = digits[v&0xf] + v >>= 4 + } + return string(buf[:]) +} diff --git a/services/updater/prestage_hwtest_types_tinygo.go b/services/updater/prestage_hwtest_types_tinygo.go new file mode 100644 index 0000000..93281c3 --- /dev/null +++ b/services/updater/prestage_hwtest_types_tinygo.go @@ -0,0 +1,16 @@ +//go:build tinygo && rp2350 && fabric_uart_hwtest + +package updater + +// streamedStage is shared by the production RP2350 prestage path and the +// fabric_uart_hwtest prestage sink. The production definition lives in +// prestage_tinygo.go, which is deliberately excluded under fabric_uart_hwtest +// so that the hardware UART/Fabric interconnection test does not write the +// inactive A/B flash slot. +type streamedStage struct { + Version string + BuildID string + ImageID string + Length uint32 + PayloadSHA256 string +} diff --git a/services/updater/prestage_tinygo.go b/services/updater/prestage_tinygo.go index 88c3d6d..56f215a 100644 --- a/services/updater/prestage_tinygo.go +++ b/services/updater/prestage_tinygo.go @@ -1,4 +1,4 @@ -//go:build tinygo && rp2350 +//go:build tinygo && rp2350 && !fabric_uart_hwtest package updater @@ -96,8 +96,8 @@ func writeStreamedStage(xferID string, generation uint64, data []byte) error { return err } -func commitStreamedStage(xferID string, generation uint64) (streamedStage, error) { - _, _ = xferID, generation +func commitStreamedStage(svc *Service, xferID string, generation uint64) (streamedStage, error) { + _, _, _ = svc, xferID, generation if streamedVerifier == nil { return streamedStage{}, errors.New("streamed_stage_not_started") } diff --git a/services/updater/receiver.go b/services/updater/receiver.go index dce757f..82d3e2e 100644 --- a/services/updater/receiver.go +++ b/services/updater/receiver.go @@ -39,7 +39,7 @@ func (s *Service) handleStage(msg *bus.Message) { return } - staged, ok := consumeStreamedStageResult() + staged, ok := s.consumeStreamedStageResult() if !ok { s.failStage(payload, "artefact_missing") s.reply(msg, StageReply{OK: false, Err: "artefact_missing"}) diff --git a/services/updater/rpc.go b/services/updater/rpc.go index 825d0f2..cc0b887 100644 --- a/services/updater/rpc.go +++ b/services/updater/rpc.go @@ -15,8 +15,8 @@ func (s *Service) handlePrepare(msg *bus.Message) { prepareAt := time.Now() req, ok := jsonDecode[PrepareRequest](msg.Payload) if !ok { - otadiag.Event("[updater-stream]", "prepare_reject", otadiag.XferNone, otadiag.KV("reason", "bad_request")) - s.reply(msg, Reply{OK: false, Error: "bad_request"}) + otadiag.Event("[updater-stream]", "prepare_reject", otadiag.XferNone, otadiag.KV("reason", ErrInvalidRequest)) + s.reply(msg, Reply{OK: false, Error: ErrInvalidRequest}) return } otadiag.Event( @@ -26,8 +26,8 @@ func (s *Service) handlePrepare(msg *bus.Message) { otadiag.KV("expected_image_id", req.ExpectedImageID), ) if req.Target != "" && req.Target != PrepareTargetMCU { - otadiag.Event("[updater-stream]", "prepare_reject", otadiag.XferNone, otadiag.KV("reason", ErrTargetMismatch)) - s.reply(msg, Reply{OK: false, Error: ErrTargetMismatch}) + otadiag.Event("[updater-stream]", "prepare_reject", otadiag.XferNone, otadiag.KV("reason", ErrUnsupportedTarget)) + s.reply(msg, Reply{OK: false, Error: ErrUnsupportedTarget}) return } @@ -103,7 +103,7 @@ func (s *Service) handlePrepare(msg *bus.Message) { func (s *Service) handleCommit(msg *bus.Message) { req, ok := jsonDecode[CommitRequest](msg.Payload) if !ok { - s.reply(msg, Reply{OK: false, Error: "bad_request"}) + s.reply(msg, Reply{OK: false, Error: ErrInvalidRequest}) return } @@ -119,7 +119,7 @@ func (s *Service) handleCommit(msg *bus.Message) { return } if !present || !stagedInState { - s.reply(msg, Reply{OK: false, Error: ErrNothingStaged}) + s.reply(msg, Reply{OK: false, Error: ErrNoStagedImage}) return } expectedImageID := pendingImageID @@ -127,14 +127,14 @@ func (s *Service) handleCommit(msg *bus.Message) { expectedImageID = req.ExpectedImageID } if expectedImageID != "" && desc.ImageID != expectedImageID { - s.reply(msg, Reply{OK: false, Error: ErrTargetMismatch}) + s.reply(msg, Reply{OK: false, Error: ErrImageIDMismatch}) return } // Validate the apply path before publishing committing/rebooting or // replying accepted. The default Applier refuses in non-hardware tests. if err := s.applier.CanApply(desc); err != nil { - s.reply(msg, Reply{OK: false, Error: err.Error()}) + s.reply(msg, Reply{OK: false, Error: ErrApplyUnavailable}) return } diff --git a/services/updater/stream_lease.go b/services/updater/stream_lease.go index 82dfef6..9f65ab6 100644 --- a/services/updater/stream_lease.go +++ b/services/updater/stream_lease.go @@ -1,170 +1,444 @@ package updater import ( + "context" "errors" - "sync" "time" "devicecode-go/services/otadiag" ) -var ( - activeServiceMu sync.Mutex - activeService *Service +type streamedStageCommandKind uint8 + +const ( + streamedStageCommandBegin streamedStageCommandKind = iota + 1 + streamedStageCommandWrite + streamedStageCommandCommit + streamedStageCommandAbort + streamedStageCommandCancel ) -func registerActiveService(s *Service) func() { - activeServiceMu.Lock() - activeService = s - activeServiceMu.Unlock() - return func() { - activeServiceMu.Lock() - if activeService == s { - activeService = nil +type streamedStageCommand struct { + kind streamedStageCommandKind + xferID string + generation uint64 + size uint32 + data []byte + reason string + reply chan streamedStageCommandResult +} + +type streamedStageCommandResult struct { + generation uint64 + written uint32 + err error +} + +type streamedStageWorkerCommand struct { + kind streamedStageCommandKind + xferID string + generation uint64 + size uint32 + data []byte + reason string +} + +type streamedStageWorkerResult struct { + kind streamedStageCommandKind + xferID string + generation uint64 + staged streamedStage + err error +} + +// BeginStreamedStage submits a transfer-begin operation to the updater reactor. +// The updater loop owns the lease/state decision; the stage worker owns any +// verifier or flash setup needed to accept the stream. +func (s *Service) BeginStreamedStage(xferID string, size uint32) (uint64, error) { + res := s.submitStreamedStageCommand(streamedStageCommand{ + kind: streamedStageCommandBegin, + xferID: xferID, + size: size, + }) + return res.generation, res.err +} + +func (s *Service) WriteStreamedStage(xferID string, generation uint64, data []byte) error { + res := s.submitStreamedStageCommand(streamedStageCommand{ + kind: streamedStageCommandWrite, + xferID: xferID, + generation: generation, + data: data, + }) + return res.err +} + +func (s *Service) CommitStreamedStage(xferID string, generation uint64) (uint32, error) { + res := s.submitStreamedStageCommand(streamedStageCommand{ + kind: streamedStageCommandCommit, + xferID: xferID, + generation: generation, + }) + return res.written, res.err +} + +func (s *Service) AbortStreamedStage(xferID string, generation uint64, reason string) { + _ = s.submitStreamedStageCommand(streamedStageCommand{ + kind: streamedStageCommandAbort, + xferID: xferID, + generation: generation, + reason: reason, + }) +} + +func (s *Service) CancelStreamedStage(xferID string, generation uint64, reason string) { + _ = s.submitStreamedStageCommand(streamedStageCommand{ + kind: streamedStageCommandCancel, + xferID: xferID, + generation: generation, + reason: reason, + }) +} + +func (s *Service) submitStreamedStageCommand(cmd streamedStageCommand) streamedStageCommandResult { + if s == nil { + return streamedStageCommandResult{err: errors.New("updater_not_running")} + } + select { + case <-s.stageStopped: + return streamedStageCommandResult{err: errors.New("updater_not_running")} + default: + } + select { + case <-s.stageReady: + default: + return streamedStageCommandResult{err: errors.New("updater_not_running")} + } + cmd.reply = make(chan streamedStageCommandResult, 1) + select { + case s.stageCommands <- cmd: + case <-s.stageStopped: + return streamedStageCommandResult{err: errors.New("updater_not_running")} + } + select { + case res := <-cmd.reply: + return res + case <-s.stageStopped: + return streamedStageCommandResult{err: errors.New("updater_not_running")} + } +} + +func (s *Service) runStreamedStageWorker(ctx context.Context) { + for { + select { + case <-ctx.Done(): + abortStreamedStage() + s.clearActiveABUpdateDiagHook() + return + case cmd, ok := <-s.stageWorkerCommands: + if !ok { + abortStreamedStage() + s.clearActiveABUpdateDiagHook() + return + } + res := streamedStageWorkerResult{kind: cmd.kind, xferID: cmd.xferID, generation: cmd.generation} + switch cmd.kind { + case streamedStageCommandBegin: + res.err = startStreamedStage(cmd.xferID, cmd.generation, cmd.size) + case streamedStageCommandWrite: + res.err = writeStreamedStage(cmd.xferID, cmd.generation, cmd.data) + case streamedStageCommandCommit: + res.staged, res.err = commitStreamedStage(s, cmd.xferID, cmd.generation) + case streamedStageCommandAbort, streamedStageCommandCancel: + abortStreamedStage() + clearABUpdateDiagHookFor(cmd.xferID, cmd.generation) + default: + res.err = errors.New("bad_stage_command") + } + select { + case s.stageWorkerResults <- res: + case <-ctx.Done(): + abortStreamedStage() + s.clearActiveABUpdateDiagHook() + return + } } - activeServiceMu.Unlock() } } -func currentService() *Service { - activeServiceMu.Lock() - defer activeServiceMu.Unlock() - return activeService +func (s *Service) handleStreamedStageCommand(cmd streamedStageCommand) { + if cmd.reply == nil { + return + } + if s.pendingStageCommand != nil { + switch cmd.kind { + case streamedStageCommandAbort, streamedStageCommandCancel: + s.cancelPendingStreamedStage(cmd) + default: + cmd.reply <- streamedStageCommandResult{err: errors.New(ErrBusy)} + } + return + } + switch cmd.kind { + case streamedStageCommandBegin: + s.startStreamedStageBegin(cmd) + case streamedStageCommandWrite: + s.startStreamedStageWrite(cmd) + case streamedStageCommandCommit: + s.startStreamedStageCommit(cmd) + case streamedStageCommandAbort, streamedStageCommandCancel: + s.startStreamedStageAbort(cmd) + default: + cmd.reply <- streamedStageCommandResult{err: errors.New("bad_stage_command")} + } } -// BeginStreamedStage acquires the updater-owned staging lease opened by the -// last successful prepare-update call. Fabric calls this from xfer_begin before -// any sink mutates flash or buffers transfer state. -func BeginStreamedStage(xferID string, size uint32) (uint64, error) { - beginAt := time.Now() - otadiag.SetActiveXfer(xferID) - otadiag.Event("[updater-stream]", "begin_entry", xferID, otadiag.KV("size", size)) - s := currentService() - if s == nil { - otadiag.Event( - "[updater-stream]", "begin_error", xferID, - otadiag.KV("err", "updater_not_running"), - otadiag.KV("dur_ms", int(time.Since(beginAt)/time.Millisecond)), - ) - otadiag.StopUpdateWindow("updater_not_running") - return 0, errors.New("updater_not_running") +func (s *Service) cancelPendingStreamedStage(cmd streamedStageCommand) { + if cmd.reason == "" { + cmd.reason = "abort" } - gen, err := s.beginStreamedStageLease(xferID) + // The updater reactor owns logical cancellation even while a flash/verifier + // worker command is in progress. The worker cannot be interrupted inside a + // bounded operation, but its eventual result will be rejected by the lease + // checks because the lease is cancelled here first. + s.cancelStreamedStageLease(cmd.xferID, cmd.generation, cmd.reason) + clearABUpdateDiagHookFor(cmd.xferID, cmd.generation) + s.queueStageWorkerAbort(cmd.xferID, cmd.generation, cmd.reason) + cmd.reply <- streamedStageCommandResult{} +} + +func (s *Service) startStreamedStageBegin(cmd streamedStageCommand) { + beginAt := time.Now() + otadiag.SetActiveXfer(cmd.xferID) + otadiag.Event("[updater-stream]", "begin_entry", cmd.xferID, otadiag.KV("size", cmd.size)) + gen, err := s.beginStreamedStageLease(cmd.xferID) if err != nil { otadiag.Event( - "[updater-stream]", "lease_error", xferID, + "[updater-stream]", "lease_error", cmd.xferID, otadiag.KV("err", err.Error()), otadiag.KV("dur_ms", int(time.Since(beginAt)/time.Millisecond)), ) - return 0, err + cmd.reply <- streamedStageCommandResult{err: err} + return } - otadiag.Event("[updater-stream]", "lease_ok", xferID, otadiag.KV("generation", gen)) - installABUpdateDiagHook(xferID, gen) - startAt := time.Now() - otadiag.Event( - "[updater-stream]", "start_entry", xferID, - otadiag.KV("generation", gen), - otadiag.KV("size", size), - ) - if err := startStreamedStage(xferID, gen, size); err != nil { - otadiag.Event( - "[updater-stream]", "start_error", xferID, - otadiag.KV("generation", gen), - otadiag.KV("err", err.Error()), - otadiag.KV("dur_ms", int(time.Since(startAt)/time.Millisecond)), - ) - clearABUpdateDiagHook() - s.cancelStreamedStageLease(xferID, gen, err.Error()) + otadiag.Event("[updater-stream]", "lease_ok", cmd.xferID, otadiag.KV("generation", gen)) + installABUpdateDiagHook(cmd.xferID, gen) + cmd.generation = gen + s.pendingStageCommand = &cmd + s.sendStageWorkerCommand(streamedStageWorkerCommand{ + kind: streamedStageCommandBegin, + xferID: cmd.xferID, + generation: gen, + size: cmd.size, + }) +} + +func (s *Service) startStreamedStageWrite(cmd streamedStageCommand) { + if err := s.checkStreamedStageLease(cmd.xferID, cmd.generation, false); err != nil { + cmd.reply <- streamedStageCommandResult{err: err} + return + } + s.pendingStageCommand = &cmd + s.sendStageWorkerCommand(streamedStageWorkerCommand{ + kind: streamedStageCommandWrite, + xferID: cmd.xferID, + generation: cmd.generation, + data: cmd.data, + }) +} + +func (s *Service) startStreamedStageCommit(cmd streamedStageCommand) { + if err := s.checkStreamedStageLease(cmd.xferID, cmd.generation, false); err != nil { + cmd.reply <- streamedStageCommandResult{err: err} + return + } + s.pendingStageCommand = &cmd + s.sendStageWorkerCommand(streamedStageWorkerCommand{ + kind: streamedStageCommandCommit, + xferID: cmd.xferID, + generation: cmd.generation, + }) +} + +func (s *Service) startStreamedStageAbort(cmd streamedStageCommand) { + if cmd.reason == "" { + cmd.reason = "abort" + } + // Mark the logical lease cancelled in the updater reactor first. The + // worker then performs the storage/verifier abort at its next safe point. + if cmd.kind == streamedStageCommandCancel { + s.cancelStreamedStageLease(cmd.xferID, cmd.generation, cmd.reason) + } else { + s.cancelStreamedStageLease(cmd.xferID, cmd.generation, cmd.reason) + } + clearABUpdateDiagHookFor(cmd.xferID, cmd.generation) + s.pendingStageCommand = &cmd + s.sendStageWorkerCommand(streamedStageWorkerCommand{ + kind: cmd.kind, + xferID: cmd.xferID, + generation: cmd.generation, + reason: cmd.reason, + }) +} + +func (s *Service) sendStageWorkerCommand(cmd streamedStageWorkerCommand) { + select { + case s.stageWorkerCommands <- cmd: + default: + // The updater reactor admits only one pending command at a time, so the + // worker queue should not fill. If it does, fail and cancel the logical + // lease from the reactor rather than blocking it. + if s.pendingStageCommand != nil && s.pendingStageCommand.reply != nil { + pending := s.pendingStageCommand + s.pendingStageCommand = nil + if pending.generation != 0 { + s.cancelStreamedStageLease(pending.xferID, pending.generation, ErrBusy) + clearABUpdateDiagHookFor(pending.xferID, pending.generation) + } + pending.reply <- streamedStageCommandResult{err: errors.New(ErrBusy)} + } + } +} + +func (s *Service) handleStreamedStageWorkerResult(res streamedStageWorkerResult) { + cmd := s.pendingStageCommand + if cmd == nil || cmd.xferID != res.xferID || cmd.generation != res.generation || cmd.kind != res.kind { + // Stale worker result from an already-cancelled generation. The updater + // reactor is authoritative, so ignore it. + return + } + s.pendingStageCommand = nil + switch res.kind { + case streamedStageCommandBegin: + s.finishStreamedStageBegin(*cmd, res) + case streamedStageCommandWrite: + s.finishStreamedStageWrite(*cmd, res) + case streamedStageCommandCommit: + s.finishStreamedStageCommit(*cmd, res) + case streamedStageCommandAbort, streamedStageCommandCancel: + clearABUpdateDiagHookFor(cmd.xferID, cmd.generation) + cmd.reply <- streamedStageCommandResult{} + default: + cmd.reply <- streamedStageCommandResult{err: errors.New("bad_stage_command")} + } +} + +func (s *Service) finishStreamedStageBegin(cmd streamedStageCommand, res streamedStageWorkerResult) { + beginAt := time.Now() + if res.err != nil { + clearABUpdateDiagHookFor(cmd.xferID, cmd.generation) + s.cancelStreamedStageLease(cmd.xferID, cmd.generation, res.err.Error()) otadiag.Event( - "[updater-stream]", "begin_error", xferID, - otadiag.KV("err", err.Error()), - otadiag.KV("generation", gen), + "[updater-stream]", "begin_error", cmd.xferID, + otadiag.KV("err", res.err.Error()), + otadiag.KV("generation", cmd.generation), otadiag.KV("dur_ms", int(time.Since(beginAt)/time.Millisecond)), ) otadiag.StopUpdateWindow("start_streamed_stage_error") - return 0, err + cmd.reply <- streamedStageCommandResult{err: res.err} + return } - otadiag.Event( - "[updater-stream]", "start_exit", xferID, - otadiag.KV("generation", gen), - otadiag.KV("dur_ms", int(time.Since(startAt)/time.Millisecond)), - ) - markAt := time.Now() - otadiag.Event("[updater-stream]", "mark_receiving_entry", xferID, otadiag.KV("generation", gen)) - if err := s.markStreamedStageReceiving(xferID, gen); err != nil { - otadiag.Event( - "[updater-stream]", "mark_receiving_error", xferID, - otadiag.KV("generation", gen), - otadiag.KV("err", err.Error()), - otadiag.KV("dur_ms", int(time.Since(markAt)/time.Millisecond)), - ) - abortStreamedStage() - clearABUpdateDiagHook() - s.cancelStreamedStageLease(xferID, gen, err.Error()) + if err := s.markStreamedStageReceiving(cmd.xferID, cmd.generation); err != nil { + clearABUpdateDiagHookFor(cmd.xferID, cmd.generation) + s.cancelStreamedStageLease(cmd.xferID, cmd.generation, err.Error()) + s.queueStageWorkerAbort(cmd.xferID, cmd.generation, err.Error()) otadiag.Event( - "[updater-stream]", "begin_error", xferID, + "[updater-stream]", "begin_error", cmd.xferID, otadiag.KV("err", err.Error()), - otadiag.KV("generation", gen), + otadiag.KV("generation", cmd.generation), otadiag.KV("dur_ms", int(time.Since(beginAt)/time.Millisecond)), ) otadiag.StopUpdateWindow("mark_receiving_error") - return 0, err + cmd.reply <- streamedStageCommandResult{err: err} + return } otadiag.Event( - "[updater-stream]", "mark_receiving_exit", xferID, - otadiag.KV("generation", gen), - otadiag.KV("dur_ms", int(time.Since(markAt)/time.Millisecond)), - ) - otadiag.Event( - "[updater-stream]", "begin_exit", xferID, - otadiag.KV("generation", gen), + "[updater-stream]", "begin_exit", cmd.xferID, + otadiag.KV("generation", cmd.generation), otadiag.KV("dur_ms", int(time.Since(beginAt)/time.Millisecond)), ) - return gen, nil + cmd.reply <- streamedStageCommandResult{generation: cmd.generation} } -func WriteStreamedStage(xferID string, generation uint64, data []byte) error { - s := currentService() - if s == nil { - return errors.New("updater_not_running") +func (s *Service) finishStreamedStageWrite(cmd streamedStageCommand, res streamedStageWorkerResult) { + if res.err != nil { + s.cancelStreamedStageLease(cmd.xferID, cmd.generation, res.err.Error()) + clearABUpdateDiagHookFor(cmd.xferID, cmd.generation) + s.queueStageWorkerAbort(cmd.xferID, cmd.generation, res.err.Error()) + cmd.reply <- streamedStageCommandResult{err: res.err} + return } - if err := s.checkStreamedStageLease(xferID, generation, false); err != nil { - return err + if err := s.checkStreamedStageLease(cmd.xferID, cmd.generation, false); err != nil { + clearABUpdateDiagHookFor(cmd.xferID, cmd.generation) + s.queueStageWorkerAbort(cmd.xferID, cmd.generation, err.Error()) + cmd.reply <- streamedStageCommandResult{err: err} + return } - return writeStreamedStage(xferID, generation, data) + cmd.reply <- streamedStageCommandResult{} } -func CommitStreamedStage(xferID string, generation uint64) (uint32, error) { - s := currentService() - if s == nil { - return 0, errors.New("updater_not_running") +func (s *Service) finishStreamedStageCommit(cmd streamedStageCommand, res streamedStageWorkerResult) { + clearABUpdateDiagHookFor(cmd.xferID, cmd.generation) + if res.err != nil { + s.cancelStreamedStageLease(cmd.xferID, cmd.generation, res.err.Error()) + cmd.reply <- streamedStageCommandResult{err: res.err} + return } - if err := s.checkStreamedStageLease(xferID, generation, false); err != nil { - return 0, err + if err := s.markStreamedStageCommitted(cmd.xferID, cmd.generation); err != nil { + s.queueStageWorkerAbort(cmd.xferID, cmd.generation, err.Error()) + cmd.reply <- streamedStageCommandResult{err: err} + return } - staged, err := commitStreamedStage(xferID, generation) - clearABUpdateDiagHook() - if err != nil { - s.cancelStreamedStageLease(xferID, generation, err.Error()) - return 0, err + s.setStreamedStageResult(res.staged) + cmd.reply <- streamedStageCommandResult{written: res.staged.Length} +} + +func (s *Service) queueStageWorkerAbort(xferID string, generation uint64, reason string) { + if reason == "" { + reason = "abort" } - if err := s.markStreamedStageCommitted(xferID, generation); err != nil { - abortStreamedStage() - return 0, err + select { + case s.stageWorkerCommands <- streamedStageWorkerCommand{kind: streamedStageCommandAbort, xferID: xferID, generation: generation, reason: reason}: + default: } - return staged.Length, nil } -func AbortStreamedStage(xferID string, generation uint64, reason string) { - abortStreamedStage() - clearABUpdateDiagHook() - if s := currentService(); s != nil { - s.cancelStreamedStageLease(xferID, generation, reason) +func (s *Service) setStreamedStageResult(staged streamedStage) { + s.mu.Lock() + s.streamStageResult = staged + s.streamStageResultOK = true + s.mu.Unlock() +} + +func (s *Service) clearActiveABUpdateDiagHook() { + if s == nil { + return + } + s.mu.Lock() + xferID := s.streamXferID + generation := s.stageGeneration + s.mu.Unlock() + if xferID == "" || generation == 0 { + return + } + clearABUpdateDiagHookFor(xferID, generation) +} + +func (s *Service) consumeStreamedStageResult() (streamedStage, bool) { + s.mu.Lock() + defer s.mu.Unlock() + if !s.streamStageResultOK { + return streamedStage{}, false } + out := s.streamStageResult + s.streamStageResult = streamedStage{} + s.streamStageResultOK = false + return out, true } -func CancelStreamedStage(xferID string, generation uint64, reason string) { - AbortStreamedStage(xferID, generation, reason) +func (s *Service) discardStreamedStageResultLocked() { + s.streamStageResult = streamedStage{} + s.streamStageResultOK = false } func (s *Service) openStageGenerationLocked() uint64 { @@ -176,7 +450,7 @@ func (s *Service) openStageGenerationLocked() uint64 { s.streamXferID = "" s.streamCancelled = false s.streamCommitted = false - discardStreamedStageResult() + s.discardStreamedStageResultLocked() return s.stageGeneration } @@ -202,6 +476,7 @@ func (s *Service) beginStreamedStageLease(xferID string) (uint64, error) { s.streamXferID = xferID s.streamCancelled = false s.streamCommitted = false + s.discardStreamedStageResultLocked() snap := s.diagSnapshotLocked() gen := s.stageGeneration s.mu.Unlock() @@ -277,6 +552,7 @@ func (s *Service) cancelStreamedStageLease(xferID string, generation uint64, rea s.streamXferID = "" s.stagedImageID = "" s.pendingVersion = "" + s.discardStreamedStageResultLocked() if s.state == StateReady || s.state == StateReceiving || s.state == StateStaged { s.state = StateFailed } diff --git a/services/updater/stream_stage_actor_test.go b/services/updater/stream_stage_actor_test.go new file mode 100644 index 0000000..4ad8952 --- /dev/null +++ b/services/updater/stream_stage_actor_test.go @@ -0,0 +1,153 @@ +package updater + +import ( + "io" + "strings" + "testing" + "time" +) + +type actorBlockingVerifier struct { + entered chan struct{} + release chan struct{} + manifest Manifest +} + +func (v *actorBlockingVerifier) Verify(r io.Reader, sink SlotSink) (Manifest, error) { + select { + case <-v.entered: + default: + close(v.entered) + } + <-v.release + if _, err := io.Copy(sink, r); err != nil { + return Manifest{}, err + } + if err := sink.Commit(); err != nil { + return Manifest{}, err + } + return v.manifest, nil +} + +func TestStreamedStageActorRejectsConcurrentCommandWhileWorkerBusy(t *testing.T) { + b := newTestBus() + conn := b.NewConnection("updater") + caller := b.NewConnection("caller") + verif := &actorBlockingVerifier{ + entered: make(chan struct{}), + release: make(chan struct{}), + manifest: Manifest{ + Version: "9.9.9", + BuildID: "build-9.9.9", + ImageID: "mcu-dev-9.9.9", + PayloadSHA256: strings.Repeat("a", 64), + PayloadLength: 4, + }, + } + + svc, cancel := runService(t, b, Options{Conn: conn, Verifier: verif}) + defer cancel() + prepareUpdaterForLease(t, caller) + gen, err := svc.BeginStreamedStage("xfer-actor-busy", 4) + if err != nil { + t.Fatalf("BeginStreamedStage: %v", err) + } + if err := svc.WriteStreamedStage("xfer-actor-busy", gen, []byte("blob")); err != nil { + t.Fatalf("WriteStreamedStage: %v", err) + } + + commitErr := make(chan error, 1) + go func() { + _, err := svc.CommitStreamedStage("xfer-actor-busy", gen) + commitErr <- err + }() + select { + case <-verif.entered: + case <-time.After(2 * time.Second): + t.Fatal("verifier did not enter") + } + + if err := svc.WriteStreamedStage("xfer-actor-busy", gen, []byte("more")); err == nil || err.Error() != ErrBusy { + t.Fatalf("WriteStreamedStage while commit pending err = %v, want busy", err) + } + + close(verif.release) + select { + case err := <-commitErr: + if err != nil { + t.Fatalf("CommitStreamedStage after release: %v", err) + } + case <-time.After(2 * time.Second): + t.Fatal("commit did not complete") + } +} + +func TestStreamedStageActorCancelWhileWorkerBusyRejectsLateWorkerSuccess(t *testing.T) { + b := newTestBus() + conn := b.NewConnection("updater") + caller := b.NewConnection("caller") + observer := b.NewConnection("observer") + upSub := observer.Subscribe(TopicUpdaterFact) + defer observer.Unsubscribe(upSub) + memMD := NewMemoryMetadata() + verif := &actorBlockingVerifier{ + entered: make(chan struct{}), + release: make(chan struct{}), + manifest: Manifest{ + Version: "9.9.9", + BuildID: "build-9.9.9", + ImageID: "mcu-dev-9.9.9", + PayloadSHA256: strings.Repeat("b", 64), + PayloadLength: 4, + }, + } + + svc, cancel := runService(t, b, Options{ + Conn: conn, + Verifier: verif, + Metadata: memMD, + MetadataWrite: memMD, + }) + defer cancel() + prepareUpdaterForLease(t, caller) + gen, err := svc.BeginStreamedStage("xfer-cancel-busy", 4) + if err != nil { + t.Fatalf("BeginStreamedStage: %v", err) + } + if err := svc.WriteStreamedStage("xfer-cancel-busy", gen, []byte("blob")); err != nil { + t.Fatalf("WriteStreamedStage: %v", err) + } + + commitErr := make(chan error, 1) + go func() { + _, err := svc.CommitStreamedStage("xfer-cancel-busy", gen) + commitErr <- err + }() + select { + case <-verif.entered: + case <-time.After(2 * time.Second): + t.Fatal("verifier did not enter") + } + + svc.CancelStreamedStage("xfer-cancel-busy", gen, "outer_timeout") + failed := waitForFact[UpdaterFact](t, upSub, func(f UpdaterFact) bool { return f.State == StateFailed }) + if got := strValue(failed.LastError); got != "outer_timeout" { + t.Fatalf("last_error = %q, want outer_timeout", got) + } + if _, ok := memMD.StagedDescriptor(); ok { + t.Fatal("descriptor persisted before blocked verifier was released") + } + + close(verif.release) + select { + case err := <-commitErr: + if err == nil { + t.Fatal("CommitStreamedStage succeeded after cancellation") + } + case <-time.After(2 * time.Second): + t.Fatal("commit did not return after verifier release") + } + if _, ok := memMD.StagedDescriptor(); ok { + t.Fatal("late worker success persisted descriptor after cancellation") + } +} diff --git a/services/updater/types.go b/services/updater/types.go index 44e9dc6..d7b66c4 100644 --- a/services/updater/types.go +++ b/services/updater/types.go @@ -32,10 +32,10 @@ const ( PrepareTargetMCU = "mcu" TargetUpdaterMain = "updater/main" DigestAlgXXHash32 = "xxhash32" - // DefaultMaxChunkSize is the safe RP2350 Fabric OTA limit currently - // advertised by prepare-update. It is a target pacing limit, not a - // Fabric protocol maximum. - DefaultMaxChunkSize uint32 = 512 + // DefaultMaxChunkSize is the fabric-jsonl/1 v1 initial raw chunk size. + // The CM5 sender chooses the actual chunk size, but the MCU must accept + // at least 2048-byte chunks. + DefaultMaxChunkSize uint32 = 2048 ) // PrepareRequest mirrors the current prepare-update payload. @@ -76,16 +76,19 @@ type Reply struct { // Refusal error strings — the Lua side compares against these. const ( - ErrBusy = "busy" - ErrNothingStaged = "nothing_staged" - ErrTargetMismatch = "target_mismatch" - ErrABUpdateBuyFailed = "abupdate_buy_failed" + ErrBusy = "busy" + ErrInvalidRequest = "invalid_request" + ErrUnsupportedTarget = "unsupported_target" + ErrStorageUnavailable = "storage_unavailable" + ErrNoStagedImage = "no_staged_image" + ErrImageIDMismatch = "image_id_mismatch" + ErrABUpdateBuyFailed = "abupdate_buy_failed" // ErrApplyUnavailable is returned when the commit RPC sees a valid // staged descriptor but no Applier is wired to actually trigger // the slot-switch + reboot. Refusing by default means we never lie // to the CM5 about apply success when the hardware apply path is not // wired. - ErrApplyUnavailable = "apply_unavailable" + ErrApplyUnavailable = "commit_failed" ) // SoftwareFact is the retained payload at state/self/software. diff --git a/services/updater/updater.go b/services/updater/updater.go index 5923234..a54f11c 100644 --- a/services/updater/updater.go +++ b/services/updater/updater.go @@ -160,11 +160,22 @@ type Service struct { preparing bool bootBuyRC int32 - stageGeneration uint64 - streamLeaseActive bool - streamXferID string - streamCancelled bool - streamCommitted bool + stageGeneration uint64 + streamLeaseActive bool + streamXferID string + streamCancelled bool + streamCommitted bool + streamStageResult streamedStage + streamStageResultOK bool + + stageCommands chan streamedStageCommand + stageWorkerCommands chan streamedStageWorkerCommand + stageWorkerResults chan streamedStageWorkerResult + pendingStageCommand *streamedStageCommand + stageReady chan struct{} + stageStopped chan struct{} + stageReadyOnce sync.Once + stageStoppedOnce sync.Once applyResults chan applyRebootResult @@ -245,15 +256,20 @@ func New(opts Options) *Service { mw = noopMetadataWriter{} } s := &Service{ - conn: opts.Conn, - verifier: v, - applier: a, - identity: opts.Identity, - metadata: mr, - metadataWrite: mw, - state: StateRunning, - bootBuyRC: opts.BootBuyRC, - applyResults: make(chan applyRebootResult, 1), + conn: opts.Conn, + verifier: v, + applier: a, + identity: opts.Identity, + metadata: mr, + metadataWrite: mw, + state: StateRunning, + bootBuyRC: opts.BootBuyRC, + stageCommands: make(chan streamedStageCommand, 1), + stageWorkerCommands: make(chan streamedStageWorkerCommand, 1), + stageWorkerResults: make(chan streamedStageWorkerResult, 1), + stageReady: make(chan struct{}), + stageStopped: make(chan struct{}), + applyResults: make(chan applyRebootResult, 1), criticalRepublish: normalizeCriticalRepublishConfig( opts.CriticalRepublish, ), @@ -280,8 +296,9 @@ func (noopMetadataWriter) ClearStagedDescriptor() error { // surface, and watches the fabric link-state retain for ready-true // edges. Blocks until ctx is cancelled. func (s *Service) Run(ctx context.Context) { - unregister := registerActiveService(s) - defer unregister() + s.stageReadyOnce.Do(func() { close(s.stageReady) }) + go s.runStreamedStageWorker(ctx) + defer s.stageStoppedOnce.Do(func() { close(s.stageStopped) }) defer otadiag.StopUpdateWindow("updater_stop") prepareSub := s.conn.Subscribe(TopicPrepareRPC) @@ -383,6 +400,10 @@ func (s *Service) Run(ctx context.Context) { continue } s.handleStage(msg) + case cmd := <-s.stageCommands: + s.handleStreamedStageCommand(cmd) + case result := <-s.stageWorkerResults: + s.handleStreamedStageWorkerResult(result) case result := <-s.applyResults: s.failRebootIfCurrent(result.desc, result.err) case now := <-criticalTimerC: @@ -549,6 +570,14 @@ type linkObservation struct { LocalSID string } +// fabricLinkObserver is implemented by services/fabric's retained link-state +// payload. Keeping this as a tiny structural interface avoids JSON reflection +// in the common in-process TinyGo path while still tolerating map/JSON payloads +// in host-side tests. +type fabricLinkObserver interface { + FabricLinkObservation() (ready bool, peerSID string, localSID string) +} + func republishReason(prev, cur linkObservation, hadPrev bool) string { if !cur.Ready { return "" @@ -594,6 +623,9 @@ func decodeLinkState(msg *bus.Message) (string, linkObservation) { obs.PeerSID, _ = p["peer_sid"].(string) obs.LocalSID, _ = p["local_sid"].(string) return linkID, obs + case fabricLinkObserver: + obs.Ready, obs.PeerSID, obs.LocalSID = p.FabricLinkObservation() + return linkID, obs } // Fall back to JSON probe for the typed-struct payload that // fabric publishes via its linkStatePayload type. diff --git a/services/updater/updater_test.go b/services/updater/updater_test.go index 9cd474b..1a8670e 100644 --- a/services/updater/updater_test.go +++ b/services/updater/updater_test.go @@ -115,7 +115,7 @@ func (f *failingClearMetadata) ClearStagedDescriptor() error { // fakeApplier always succeeds — used by tests that need the commit RPC // to drive the state machine through committing/rebooting without // actually rebooting (production wiring uses RefusingApplier so the -// commit RPC returns apply_unavailable until the real abupdate-backed +// commit RPC returns commit_failed until the real abupdate-backed // implementation is supplied). // // canCalls and rebootCalls are kept separate so tests can verify the commit @@ -301,7 +301,7 @@ func testStagePayload(id string, artefact []byte) StagePayload { } } -func preparedStagePayload(t *testing.T, caller *bus.Connection, svc *Service, id string, artefact []byte) StagePayload { +func preparedStreamedStageLease(t *testing.T, caller *bus.Connection, svc *Service, id string, artefact []byte) (StagePayload, uint64) { t.Helper() req := caller.NewMessage(TopicPrepareRPC, PrepareRequest{Target: PrepareTargetMCU}, false) sub := caller.Request(req) @@ -324,20 +324,26 @@ func preparedStagePayload(t *testing.T, caller *bus.Connection, svc *Service, id case <-time.After(2 * time.Second): t.Fatal("timeout waiting for prepare reply") } - generation, err := BeginStreamedStage(id, uint32(len(artefact))) + generation, err := svc.BeginStreamedStage(id, uint32(len(artefact))) if err != nil { t.Fatalf("begin streamed stage: %v", err) } if len(artefact) > 0 { - if err := WriteStreamedStage(id, generation, artefact); err != nil { + if err := svc.WriteStreamedStage(id, generation, artefact); err != nil { t.Fatalf("write streamed stage: %v", err) } } - if _, err := CommitStreamedStage(id, generation); err != nil { - t.Fatalf("commit streamed stage: %v", err) - } payload := testStagePayload(id, artefact) payload.Generation = generation + return payload, generation +} + +func preparedStagePayload(t *testing.T, caller *bus.Connection, svc *Service, id string, artefact []byte) StagePayload { + t.Helper() + payload, generation := preparedStreamedStageLease(t, caller, svc, id, artefact) + if _, err := svc.CommitStreamedStage(id, generation); err != nil { + t.Fatalf("commit streamed stage: %v", err) + } return payload } @@ -588,14 +594,23 @@ func requestUpdaterReply(t *testing.T, caller *bus.Connection, topic bus.Topic, return nil } +func TestStreamedStageControllerRequiresUpdaterRun(t *testing.T) { + b := newTestBus() + svc := New(Options{Conn: b.NewConnection("updater")}) + + if gen, err := svc.BeginStreamedStage("xfer-not-running", 4); err == nil || err.Error() != "updater_not_running" || gen != 0 { + t.Fatalf("BeginStreamedStage before Run = gen=%d err=%v, want updater_not_running", gen, err) + } +} + func TestBeginStreamedStageBeforePrepareReturnsStageNotPrepared(t *testing.T) { b := newTestBus() conn := b.NewConnection("updater") - _, cancel := runService(t, b, Options{Conn: conn}) + svc, cancel := runService(t, b, Options{Conn: conn}) defer cancel() - if gen, err := BeginStreamedStage("xfer-before-prepare", 4); err == nil || err.Error() != "stage_not_prepared" || gen != 0 { + if gen, err := svc.BeginStreamedStage("xfer-before-prepare", 4); err == nil || err.Error() != "stage_not_prepared" || gen != 0 { t.Fatalf("BeginStreamedStage before prepare = gen=%d err=%v, want stage_not_prepared", gen, err) } } @@ -617,20 +632,20 @@ func TestPrepareOpensSingleReceivingStreamLeaseAndClearsStaleDescriptor(t *testi t.Fatal("prepare did not clear stale staged descriptor") } - gen, err := BeginStreamedStage("xfer-lease", 4) + gen, err := svc.BeginStreamedStage("xfer-lease", 4) if err != nil { t.Fatalf("BeginStreamedStage: %v", err) } if gen == 0 { t.Fatal("BeginStreamedStage returned generation 0") } - defer CancelStreamedStage("xfer-lease", gen, "test_done") + defer svc.CancelStreamedStage("xfer-lease", gen, "test_done") up := waitForFact[UpdaterFact](t, upSub, func(f UpdaterFact) bool { return f.State == StateReceiving }) if strValue(up.LastError) != "" { t.Fatalf("receiving last_error = %q, want empty", strValue(up.LastError)) } - if _, err := BeginStreamedStage("xfer-second", 4); err == nil || err.Error() != ErrBusy { + if _, err := svc.BeginStreamedStage("xfer-second", 4); err == nil || err.Error() != ErrBusy { t.Fatalf("second BeginStreamedStage err = %v, want busy", err) } if err := svc.markStreamedStageCommitted("wrong-xfer", gen); err == nil || err.Error() != "stage_generation_mismatch" { @@ -649,14 +664,14 @@ func TestPrepareAndCommitRejectWhileStreamLeaseActive(t *testing.T) { conn := b.NewConnection("updater") caller := b.NewConnection("caller") - _, cancel := runService(t, b, Options{Conn: conn}) + svc, cancel := runService(t, b, Options{Conn: conn}) defer cancel() prepareUpdaterForLease(t, caller) - gen, err := BeginStreamedStage("xfer-active", 4) + gen, err := svc.BeginStreamedStage("xfer-active", 4) if err != nil { t.Fatalf("BeginStreamedStage: %v", err) } - defer CancelStreamedStage("xfer-active", gen, "test_done") + defer svc.CancelStreamedStage("xfer-active", gen, "test_done") prepPayload := requestUpdaterReply(t, caller, TopicPrepareRPC, PrepareRequest{Target: PrepareTargetMCU}) prepReply, ok := prepPayload.(Reply) @@ -679,7 +694,7 @@ func TestStreamedStageDiagHookClearsOnCommittedStage(t *testing.T) { svc, cancel := runService(t, b, Options{Conn: conn}) defer cancel() prepareUpdaterForLease(t, caller) - gen, err := BeginStreamedStage("xfer-hook-commit", 4) + gen, err := svc.BeginStreamedStage("xfer-hook-commit", 4) if err != nil { t.Fatalf("BeginStreamedStage: %v", err) } @@ -695,22 +710,35 @@ func TestStreamedStageDiagHookClearsOnCommittedStage(t *testing.T) { } } +func TestStreamedStageDiagHookClearIsLeaseScoped(t *testing.T) { + clearABUpdateDiagHook() + installABUpdateDiagHook("current-xfer", 2) + clearABUpdateDiagHookFor("stale-xfer", 1) + if !abupdateDiagHookActiveForTest() { + t.Fatal("stale generation cleared current diagnostic hook") + } + clearABUpdateDiagHookFor("current-xfer", 2) + if abupdateDiagHookActiveForTest() { + t.Fatal("matching generation did not clear diagnostic hook") + } +} + func TestStreamedStageDiagHookClearsOnAbort(t *testing.T) { b := newTestBus() conn := b.NewConnection("updater") caller := b.NewConnection("caller") - _, cancel := runService(t, b, Options{Conn: conn}) + svc, cancel := runService(t, b, Options{Conn: conn}) defer cancel() prepareUpdaterForLease(t, caller) - gen, err := BeginStreamedStage("xfer-hook-abort", 4) + gen, err := svc.BeginStreamedStage("xfer-hook-abort", 4) if err != nil { t.Fatalf("BeginStreamedStage: %v", err) } if !abupdateDiagHookActiveForTest() { t.Fatal("diagnostic hook inactive after BeginStreamedStage") } - AbortStreamedStage("xfer-hook-abort", gen, "test_abort") + svc.AbortStreamedStage("xfer-hook-abort", gen, "test_abort") if abupdateDiagHookActiveForTest() { t.Fatal("diagnostic hook still active after abort") } @@ -721,17 +749,17 @@ func TestStreamedStageDiagHookClearsOnCommitError(t *testing.T) { conn := b.NewConnection("updater") caller := b.NewConnection("caller") - _, cancel := runService(t, b, Options{Conn: conn}) + svc, cancel := runService(t, b, Options{Conn: conn}) defer cancel() prepareUpdaterForLease(t, caller) - gen, err := BeginStreamedStage("xfer-hook-commit-error", 4) + gen, err := svc.BeginStreamedStage("xfer-hook-commit-error", 4) if err != nil { t.Fatalf("BeginStreamedStage: %v", err) } if !abupdateDiagHookActiveForTest() { t.Fatal("diagnostic hook inactive after BeginStreamedStage") } - if _, err := CommitStreamedStage("xfer-hook-commit-error", gen); err == nil { + if _, err := svc.CommitStreamedStage("xfer-hook-commit-error", gen); err == nil { t.Fatal("CommitStreamedStage returned nil error, want host streamed_stage_not_supported") } if abupdateDiagHookActiveForTest() { @@ -778,14 +806,14 @@ func TestCancelStreamedStagePreventsLateStageSuccess(t *testing.T) { }) defer cancel() prepareUpdaterForLease(t, caller) - gen, err := BeginStreamedStage("xfer-cancel", 4) + gen, err := svc.BeginStreamedStage("xfer-cancel", 4) if err != nil { t.Fatalf("BeginStreamedStage: %v", err) } if err := svc.markStreamedStageCommitted("xfer-cancel", gen); err != nil { t.Fatalf("markStreamedStageCommitted: %v", err) } - CancelStreamedStage("xfer-cancel", gen, "test_cancel") + svc.CancelStreamedStage("xfer-cancel", gen, "test_cancel") stage := testStagePayload("xfer-cancel", []byte("blob")) stage.Generation = gen @@ -815,7 +843,7 @@ func TestReleasedStagedLeaseIgnoresLateCancel(t *testing.T) { PayloadLength: 4, }} - _, cancel := runService(t, b, Options{ + svc, cancel := runService(t, b, Options{ Conn: conn, Verifier: verif, Applier: app, @@ -824,14 +852,14 @@ func TestReleasedStagedLeaseIgnoresLateCancel(t *testing.T) { }) defer cancel() prepareUpdaterForLease(t, caller) - gen, err := BeginStreamedStage("xfer-released", 4) + gen, err := svc.BeginStreamedStage("xfer-released", 4) if err != nil { t.Fatalf("BeginStreamedStage: %v", err) } - if err := WriteStreamedStage("xfer-released", gen, []byte("blob")); err != nil { + if err := svc.WriteStreamedStage("xfer-released", gen, []byte("blob")); err != nil { t.Fatalf("WriteStreamedStage: %v", err) } - if _, err := CommitStreamedStage("xfer-released", gen); err != nil { + if _, err := svc.CommitStreamedStage("xfer-released", gen); err != nil { t.Fatalf("CommitStreamedStage: %v", err) } @@ -847,7 +875,7 @@ func TestReleasedStagedLeaseIgnoresLateCancel(t *testing.T) { t.Fatal("stage did not persist descriptor") } - CancelStreamedStage("xfer-released", gen, "late_cancel") + svc.CancelStreamedStage("xfer-released", gen, "late_cancel") if _, ok := memMD.StagedDescriptor(); !ok { t.Fatal("late cancel cleared released staged descriptor") } @@ -871,7 +899,7 @@ func TestStaleGenerationAndWrongXferCannotMutateStreamedStage(t *testing.T) { PayloadLength: 4, }} - _, cancel := runService(t, b, Options{ + svc, cancel := runService(t, b, Options{ Conn: conn, Verifier: verif, Metadata: memMD, @@ -879,22 +907,22 @@ func TestStaleGenerationAndWrongXferCannotMutateStreamedStage(t *testing.T) { }) defer cancel() prepareUpdaterForLease(t, caller) - gen, err := BeginStreamedStage("xfer-current", 4) + gen, err := svc.BeginStreamedStage("xfer-current", 4) if err != nil { t.Fatalf("BeginStreamedStage: %v", err) } - defer CancelStreamedStage("xfer-current", gen, "test_done") + defer svc.CancelStreamedStage("xfer-current", gen, "test_done") - if err := WriteStreamedStage("wrong-xfer", gen, []byte("data")); err == nil || err.Error() != "stage_generation_mismatch" { + if err := svc.WriteStreamedStage("wrong-xfer", gen, []byte("data")); err == nil || err.Error() != "stage_generation_mismatch" { t.Fatalf("wrong xfer WriteStreamedStage err = %v, want generation mismatch", err) } - if _, err := CommitStreamedStage("xfer-current", gen+1); err == nil || err.Error() != "stage_generation_mismatch" { + if _, err := svc.CommitStreamedStage("xfer-current", gen+1); err == nil || err.Error() != "stage_generation_mismatch" { t.Fatalf("stale generation CommitStreamedStage err = %v, want generation mismatch", err) } - if err := WriteStreamedStage("xfer-current", gen, []byte("data")); err != nil { + if err := svc.WriteStreamedStage("xfer-current", gen, []byte("data")); err != nil { t.Fatalf("WriteStreamedStage: %v", err) } - if _, err := CommitStreamedStage("xfer-current", gen); err != nil { + if _, err := svc.CommitStreamedStage("xfer-current", gen); err != nil { t.Fatalf("CommitStreamedStage: %v", err) } @@ -942,8 +970,8 @@ func TestCommitWithoutStagedReturnsNothingStaged(t *testing.T) { if reply.OK { t.Fatalf("commit unexpectedly OK without staged image: %+v", reply) } - if reply.Error != ErrNothingStaged { - t.Fatalf("commit error = %q, want %q", reply.Error, ErrNothingStaged) + if reply.Error != ErrNoStagedImage { + t.Fatalf("commit error = %q, want %q", reply.Error, ErrNoStagedImage) } case <-time.After(2 * time.Second): t.Fatal("timeout waiting for commit reply") @@ -972,8 +1000,8 @@ func TestCommitWithoutStagedStateRefusesEvenWithDescriptor(t *testing.T) { select { case msg := <-replySub.Channel(): reply, _ := msg.Payload.(Reply) - if reply.OK || reply.Error != ErrNothingStaged { - t.Fatalf("commit reply = %+v, want refusal=nothing_staged", reply) + if reply.OK || reply.Error != ErrNoStagedImage { + t.Fatalf("commit reply = %+v, want refusal=no_staged_image", reply) } case <-time.After(2 * time.Second): t.Fatal("timeout waiting for commit reply") @@ -1010,8 +1038,8 @@ func TestCommitUsesPreparedExpectedImageOverCommitPayload(t *testing.T) { payload := requestUpdaterReply(t, caller, TopicCommitRPC, CommitRequest{ExpectedImageID: "image-B"}) reply, ok := payload.(Reply) - if !ok || reply.OK || reply.Error != ErrTargetMismatch { - t.Fatalf("commit reply = %#v, want target mismatch", payload) + if !ok || reply.OK || reply.Error != ErrImageIDMismatch { + t.Fatalf("commit reply = %#v, want image id mismatch", payload) } canCalls, rebootCalls := app.callCounts() if canCalls != 0 || rebootCalls != 0 { @@ -1060,7 +1088,7 @@ func TestCommitWithoutApplierReturnsApplyUnavailable(t *testing.T) { case msg := <-csub.Channel(): reply, _ := msg.Payload.(Reply) if reply.OK || reply.Error != ErrApplyUnavailable { - t.Fatalf("commit reply = %+v, want refusal=apply_unavailable", reply) + t.Fatalf("commit reply = %+v, want refusal=commit_failed", reply) } case <-time.After(2 * time.Second): t.Fatal("timeout waiting for commit reply") @@ -1234,21 +1262,9 @@ func TestStageStubVerifierPublishesFailed(t *testing.T) { svc, cancel := runService(t, b, Options{Conn: conn, Verifier: StubVerifier()}) defer cancel() - req := caller.NewMessage(TopicStageRPC, preparedStagePayload(t, caller, svc, "xfer-1", []byte("blob")), false) - replySub := caller.Request(req) - defer caller.Unsubscribe(replySub) - - select { - case msg := <-replySub.Channel(): - reply, ok := msg.Payload.(StageReply) - if !ok || reply.OK { - t.Fatalf("stage unexpectedly OK with stub: %+v", reply) - } - if !strings.Contains(reply.Err, "verifier_stub") { - t.Fatalf("stage err = %q, want stub sentinel", reply.Err) - } - case <-time.After(2 * time.Second): - t.Fatal("timeout waiting for stage reply") + _, generation := preparedStreamedStageLease(t, caller, svc, "xfer-1", []byte("blob")) + if _, err := svc.CommitStreamedStage("xfer-1", generation); err == nil || !strings.Contains(err.Error(), "verifier_stub") { + t.Fatalf("commit streamed stage err = %v, want stub sentinel", err) } up := waitForFact[UpdaterFact](t, upSub, func(f UpdaterFact) bool { return f.State == StateFailed }) @@ -1412,11 +1428,13 @@ func TestStageFakeAcceptWritesStagedDescriptor(t *testing.T) { func TestStageFailureClearsStaleStagedDescriptor(t *testing.T) { // A (stage A) -> (prepare for B) -> (stage B fails) flow must not leave - // descriptor A persisted. The next commit should return nothing_staged + // descriptor A persisted. The next commit should return no_staged_image // rather than committing stale firmware. b := newTestBus() conn := b.NewConnection("updater") caller := b.NewConnection("caller") + upSub := caller.Subscribe(TopicUpdaterFact) + defer caller.Unsubscribe(upSub) // Pre-stage: a real descriptor sitting in metadata from an earlier // successful flow. @@ -1434,22 +1452,19 @@ func TestStageFailureClearsStaleStagedDescriptor(t *testing.T) { }) defer cancel() - // Drive updater/main staging to failure. - rreq := caller.NewMessage(TopicStageRPC, preparedStagePayload(t, caller, svc, "x", []byte("blob")), false) - rsub := caller.Request(rreq) - defer caller.Unsubscribe(rsub) - select { - case <-rsub.Channel(): - case <-time.After(2 * time.Second): - t.Fatal("timeout") + // Drive updater/main streamed staging to verifier failure. + _, generation := preparedStreamedStageLease(t, caller, svc, "x", []byte("blob")) + if _, err := svc.CommitStreamedStage("x", generation); err == nil || err.Error() != "bad_signature" { + t.Fatalf("commit streamed stage err = %v, want bad_signature", err) } + _ = waitForFact[UpdaterFact](t, upSub, func(f UpdaterFact) bool { return f.State == StateFailed }) // The stale descriptor must have been cleared. if _, ok := memMD.StagedDescriptor(); ok { t.Fatalf("stale staged descriptor survived receiver failure") } - // Commit must refuse with nothing_staged rather than commit the + // Commit must refuse with no_staged_image rather than commit the // stale image. creq := caller.NewMessage(TopicCommitRPC, CommitRequest{}, false) csub := caller.Request(creq) @@ -1457,8 +1472,8 @@ func TestStageFailureClearsStaleStagedDescriptor(t *testing.T) { select { case msg := <-csub.Channel(): reply, _ := msg.Payload.(Reply) - if reply.OK || reply.Error != ErrNothingStaged { - t.Fatalf("commit reply = %+v, want refusal=nothing_staged", reply) + if reply.OK || reply.Error != ErrNoStagedImage { + t.Fatalf("commit reply = %+v, want refusal=no_staged_image", reply) } case <-time.After(2 * time.Second): t.Fatal("timeout") @@ -1606,21 +1621,9 @@ func TestStageFakeRejectPublishesFailed(t *testing.T) { svc, cancel := runService(t, b, Options{Conn: conn, Verifier: verif}) defer cancel() - req := caller.NewMessage(TopicStageRPC, preparedStagePayload(t, caller, svc, "xfer-3", []byte("blob")), false) - replySub := caller.Request(req) - defer caller.Unsubscribe(replySub) - - select { - case msg := <-replySub.Channel(): - reply, ok := msg.Payload.(StageReply) - if !ok || reply.OK { - t.Fatalf("stage unexpectedly OK: %+v", reply) - } - if reply.Err != "manifest_check_failed" { - t.Fatalf("stage err = %q, want manifest_check_failed", reply.Err) - } - case <-time.After(2 * time.Second): - t.Fatal("timeout waiting for stage reply") + _, generation := preparedStreamedStageLease(t, caller, svc, "xfer-3", []byte("blob")) + if _, err := svc.CommitStreamedStage("xfer-3", generation); err == nil || err.Error() != "manifest_check_failed" { + t.Fatalf("commit streamed stage err = %v, want manifest_check_failed", err) } up := waitForFact[UpdaterFact](t, upSub, func(f UpdaterFact) bool { return f.State == StateFailed }) diff --git a/services/updater/verifier.go b/services/updater/verifier.go index bf376dd..d6a25a1 100644 --- a/services/updater/verifier.go +++ b/services/updater/verifier.go @@ -79,7 +79,7 @@ type Applier interface { // refusingApplier is the production default. CanApply always returns // ErrApplyUnavailable so commit refuses with -// `error: "apply_unavailable"` and never reaches ArmReboot. +// `error: "commit_failed"` and never reaches ArmReboot. type refusingApplier struct{} // RefusingApplier returns the safe-default Applier for this branch. diff --git a/tools/fabric_uart_xfer_smoke.py b/tools/fabric_uart_xfer_smoke.py new file mode 100755 index 0000000..4c8ea16 --- /dev/null +++ b/tools/fabric_uart_xfer_smoke.py @@ -0,0 +1,359 @@ +#!/usr/bin/env python3 +"""Direct fabric-jsonl/1 UART smoke test for the MCU updater/main transfer path. + +This is a host-side diagnostic helper. It speaks the CM5 side of the MCU Fabric +link directly over a serial TTY, without starting the Lua services. It is meant +for the MCU build tagged `fabric_uart_hwtest`, where updater/main staging is a +safe digest/count sink rather than the production A/B flash writer. + +Example: + + python3 tools/fabric_uart_xfer_smoke.py /dev/cu.usbserial-110 --size 1024 + +The script performs: + + hello -> hello_ack + prepare-update RPC + xfer_begin / xfer_chunk* / xfer_commit to updater/main + waits for xfer_done and, where visible, state/self/updater=staged + +It deliberately does not require a successful commit-update. In the default +hardware-test build the applier is still refusing, so commit-update should be +left to a later gate. +""" + +from __future__ import annotations + +import argparse +import base64 +import json +import os +import select +import sys +import termios +import time +import tty +from dataclasses import dataclass +from typing import Any, Dict, Iterable, List, Optional + +PROTO = "fabric-jsonl/1" +DEFAULT_NODE = "bigbox-cm5" +DEFAULT_PEER = "mcu" +DEFAULT_TARGET = "updater/main" +DEFAULT_EXPECTED_IMAGE = "hwtest-image" +DEFAULT_BAUD = 115200 + +# Reflected polynomial constants for xxHash32, seed 0. +P1 = 0x9E3779B1 +P2 = 0x85EBCA77 +P3 = 0xC2B2AE3D +P4 = 0x27D4EB2F +P5 = 0x165667B1 + + +def _u32(v: int) -> int: + return v & 0xFFFFFFFF + + +def _rotl32(x: int, r: int) -> int: + return _u32((x << r) | (x >> (32 - r))) + + +def _round(acc: int, lane: int) -> int: + acc = _u32(acc + lane * P2) + acc = _rotl32(acc, 13) + acc = _u32(acc * P1) + return acc + + +def _read32le(data: bytes, off: int) -> int: + return data[off] | (data[off + 1] << 8) | (data[off + 2] << 16) | (data[off + 3] << 24) + + +def xxhash32(data: bytes, seed: int = 0) -> int: + n = len(data) + p = 0 + if n >= 16: + v1 = _u32(seed + P1 + P2) + v2 = _u32(seed + P2) + v3 = _u32(seed) + v4 = _u32(seed - P1) + limit = n - 16 + while p <= limit: + v1 = _round(v1, _read32le(data, p)); p += 4 + v2 = _round(v2, _read32le(data, p)); p += 4 + v3 = _round(v3, _read32le(data, p)); p += 4 + v4 = _round(v4, _read32le(data, p)); p += 4 + h = _u32(_rotl32(v1, 1) + _rotl32(v2, 7) + _rotl32(v3, 12) + _rotl32(v4, 18)) + else: + h = _u32(seed + P5) + h = _u32(h + n) + while p + 4 <= n: + h = _u32(h + _read32le(data, p) * P3) + h = _u32(_rotl32(h, 17) * P4) + p += 4 + while p < n: + h = _u32(h + data[p] * P5) + h = _u32(_rotl32(h, 11) * P1) + p += 1 + h ^= h >> 15 + h = _u32(h * P2) + h ^= h >> 13 + h = _u32(h * P3) + h ^= h >> 16 + return _u32(h) + + +def digest_hex(data: bytes) -> str: + return f"{xxhash32(data):08x}" + + +def b64url_unpadded(data: bytes) -> str: + return base64.urlsafe_b64encode(data).decode("ascii").rstrip("=") + + +@dataclass +class SerialRawConfig: + old_attrs: List[Any] + + +class FabricTTY: + def __init__(self, path: str, baud: int, verbose: bool = False) -> None: + self.path = path + self.verbose = verbose + self.fd = os.open(path, os.O_RDWR | os.O_NOCTTY | os.O_NONBLOCK) + self._config = self._configure(baud) + self._rx = bytearray() + + def _configure(self, baud: int) -> SerialRawConfig: + old = termios.tcgetattr(self.fd) + attrs = termios.tcgetattr(self.fd) + tty.setraw(self.fd, termios.TCSANOW) + attrs = termios.tcgetattr(self.fd) + speed = getattr(termios, f"B{baud}", None) + if speed is None: + raise RuntimeError(f"unsupported baud {baud} on this platform") + attrs[4] = speed + attrs[5] = speed + attrs[2] |= termios.CLOCAL | termios.CREAD + if hasattr(termios, "CRTSCTS"): + attrs[2] &= ~termios.CRTSCTS + attrs[2] &= ~termios.CSTOPB + attrs[2] &= ~termios.PARENB + attrs[2] &= ~termios.CSIZE + attrs[2] |= termios.CS8 + attrs[6][termios.VMIN] = 0 + attrs[6][termios.VTIME] = 0 + termios.tcsetattr(self.fd, termios.TCSANOW, attrs) + termios.tcflush(self.fd, termios.TCIOFLUSH) + return SerialRawConfig(old_attrs=old) + + def close(self) -> None: + try: + termios.tcsetattr(self.fd, termios.TCSANOW, self._config.old_attrs) + finally: + os.close(self.fd) + + def write_msg(self, msg: Dict[str, Any]) -> None: + line = json.dumps(msg, separators=(",", ":")).encode("utf-8") + b"\n" + if self.verbose: + print(">", line.decode("utf-8").rstrip()) + off = 0 + while off < len(line): + try: + n = os.write(self.fd, line[off:]) + except BlockingIOError: + select.select([], [self.fd], [], 0.25) + continue + if n > 0: + off += n + + def read_msg(self, timeout_s: float) -> Dict[str, Any]: + deadline = time.monotonic() + timeout_s + while True: + newline = self._rx.find(b"\n") + if newline >= 0: + raw = bytes(self._rx[:newline]).strip() + del self._rx[: newline + 1] + if not raw: + continue + try: + msg = json.loads(raw.decode("utf-8")) + except json.JSONDecodeError: + print("! ignoring malformed line from peer:", raw.decode("utf-8", "replace"), file=sys.stderr) + continue + if self.verbose: + print("<", json.dumps(msg, separators=(",", ":"))) + return msg + remaining = deadline - time.monotonic() + if remaining <= 0: + raise TimeoutError("timed out waiting for fabric frame") + r, _, _ = select.select([self.fd], [], [], min(0.25, remaining)) + if not r: + continue + try: + chunk = os.read(self.fd, 4096) + except BlockingIOError: + continue + if not chunk: + continue + self._rx.extend(chunk) + + +def wait_for(ttydev: FabricTTY, want_type: str, timeout_s: float, *, want_id: Optional[str] = None) -> Dict[str, Any]: + deadline = time.monotonic() + timeout_s + while True: + remaining = deadline - time.monotonic() + if remaining <= 0: + raise TimeoutError(f"timed out waiting for {want_type}") + msg = ttydev.read_msg(remaining) + mtype = msg.get("type") + if mtype == "ping": + ttydev.write_msg({"type": "pong", "sid": msg.get("sid", "")}) + continue + if mtype == "pub": + topic = "/".join(str(x) for x in msg.get("topic", [])) + payload = msg.get("payload") + if topic in {"state/self/software", "state/self/updater", "state/self/health"}: + print(f"pub {topic}: {json.dumps(payload, separators=(',', ':'))}") + if want_type == "pub" and (want_id is None or topic == want_id): + return msg + continue + if mtype == want_type and (want_id is None or msg.get("id") == want_id or msg.get("xfer_id") == want_id): + return msg + if mtype == "xfer_abort": + raise RuntimeError(f"peer aborted transfer {msg.get('xfer_id')}: {msg.get('err', '')}") + if mtype == "reply" and want_type == "reply" and want_id is not None and msg.get("id") != want_id: + continue + # Other protocol frames are expected during bring-up and retained export. + + +def payload_bytes(size: int) -> bytes: + # Deterministic content with enough variation to exercise chunk digests. + return bytes(((i * 37 + 11) & 0xFF) for i in range(size)) + + +def transfer(ttydev: FabricTTY, xfer_id: str, target: str, payload: bytes, chunk_size: int, timeout_s: float) -> None: + whole = digest_hex(payload) + ttydev.write_msg({ + "type": "xfer_begin", + "xfer_id": xfer_id, + "target": target, + "size": len(payload), + "digest_alg": "xxhash32", + "digest": whole, + "meta": {"source": "tools/fabric_uart_xfer_smoke.py"}, + }) + wait_for(ttydev, "xfer_ready", timeout_s, want_id=xfer_id) + off = 0 + while off < len(payload): + part = payload[off : off + chunk_size] + ttydev.write_msg({ + "type": "xfer_chunk", + "xfer_id": xfer_id, + "offset": off, + "data": b64url_unpadded(part), + "chunk_digest": digest_hex(part), + }) + off += len(part) + need = wait_for(ttydev, "xfer_need", timeout_s, want_id=xfer_id) + nxt = int(need.get("next", -1)) + if nxt != off: + raise RuntimeError(f"unexpected xfer_need next={nxt}, want {off}") + print(f"chunk ack next={off}") + ttydev.write_msg({ + "type": "xfer_commit", + "xfer_id": xfer_id, + "size": len(payload), + "digest_alg": "xxhash32", + "digest": whole, + }) + wait_for(ttydev, "xfer_done", timeout_s, want_id=xfer_id) + + +def main(argv: Optional[Iterable[str]] = None) -> int: + p = argparse.ArgumentParser(description="Direct fabric-jsonl/1 UART updater/main transfer smoke test") + p.add_argument("tty", help="serial device connected to the MCU Fabric UART") + p.add_argument("--baud", type=int, default=DEFAULT_BAUD) + p.add_argument("--size", type=int, default=1024, help="test payload bytes") + p.add_argument("--chunk-size", type=int, default=256, help="raw bytes per xfer_chunk; keep <= 2048") + p.add_argument("--timeout", type=float, default=10.0) + p.add_argument("--node", default=DEFAULT_NODE) + p.add_argument("--peer", default=DEFAULT_PEER) + p.add_argument("--target", default=DEFAULT_TARGET) + p.add_argument("--expected-image", default=DEFAULT_EXPECTED_IMAGE) + p.add_argument("--job-id", default=None) + p.add_argument("--verbose", action="store_true") + args = p.parse_args(list(argv) if argv is not None else None) + + if args.chunk_size <= 0 or args.chunk_size > 2048: + p.error("--chunk-size must be in 1..2048") + if args.size <= 0: + p.error("--size must be positive") + + sid = f"cm5-smoke-{int(time.time())}" + job_id = args.job_id or f"smoke-{int(time.time())}" + xfer_id = f"xfer-{job_id}" + payload = payload_bytes(args.size) + + ttydev = FabricTTY(args.tty, args.baud, args.verbose) + try: + print(f"hello sid={sid} node={args.node} peer={args.peer}") + ttydev.write_msg({"type": "hello", "proto": PROTO, "sid": sid, "node": args.node}) + ack = wait_for(ttydev, "hello_ack", args.timeout) + if ack.get("node") != args.peer or ack.get("proto") != PROTO: + raise RuntimeError(f"bad hello_ack: {ack}") + print(f"link up peer_sid={ack.get('sid')}") + + call_id = f"prepare-{job_id}" + ttydev.write_msg({ + "type": "call", + "id": call_id, + "topic": ["cap", "self", "updater", "main", "rpc", "prepare-update"], + "payload": { + "job_id": job_id, + "target": "mcu", + "expected_image_id": args.expected_image, + "metadata": {"source": "fabric_uart_xfer_smoke"}, + }, + }) + reply = wait_for(ttydev, "reply", args.timeout, want_id=call_id) + if not reply.get("ok"): + raise RuntimeError(f"prepare rejected: {reply}") + prep = reply.get("payload") or {} + if prep.get("target") != args.target: + raise RuntimeError(f"prepare returned target {prep.get('target')!r}, want {args.target!r}") + max_chunk = int(prep.get("max_chunk_size") or 0) + if max_chunk and args.chunk_size > max_chunk: + raise RuntimeError(f"chunk-size {args.chunk_size} exceeds prepare max_chunk_size {max_chunk}") + print(f"prepare ok target={prep.get('target')} max_chunk_size={max_chunk or 'unknown'}") + + print(f"transfer xfer_id={xfer_id} size={len(payload)} digest={digest_hex(payload)} chunk_size={args.chunk_size}") + transfer(ttydev, xfer_id, args.target, payload, args.chunk_size, args.timeout) + print("transfer done") + + # Give retained state export a brief chance to show staged state. The + # transfer result is authoritative for this smoke test, so this is + # informational rather than a hard requirement. + deadline = time.monotonic() + 3.0 + while time.monotonic() < deadline: + try: + msg = ttydev.read_msg(max(0.1, deadline - time.monotonic())) + except TimeoutError: + break + if msg.get("type") == "ping": + ttydev.write_msg({"type": "pong", "sid": msg.get("sid", "")}) + continue + if msg.get("type") == "pub": + topic = "/".join(str(x) for x in msg.get("topic", [])) + payload_obj = msg.get("payload") + if topic in {"state/self/software", "state/self/updater", "state/self/health"}: + print(f"pub {topic}: {json.dumps(payload_obj, separators=(',', ':'))}") + print("smoke ok") + return 0 + finally: + ttydev.close() + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/utilities/jsonw.go b/utilities/jsonw.go index 830edbc..355a80d 100644 --- a/utilities/jsonw.go +++ b/utilities/jsonw.go @@ -1,86 +1,86 @@ -package utilities - -import "devicecode-go/x/strconvx" - -// ----------------------------------------------------------------------------- -// Minimal streaming JSON writer for shmring (no buffers/allocs) -// ----------------------------------------------------------------------------- - -type JSONWriter struct { - Write func([]byte) int - first bool -} - -func (w *JSONWriter) Begin() { - w.first = true - if w.Write != nil { - w.Write([]byte("{")) - } -} -func (w *JSONWriter) End() { - if w.Write != nil { - w.Write([]byte("}\n")) - } -} -func (w *JSONWriter) Comma() { - if w.Write == nil { - return - } - if !w.first { - w.Write([]byte(",")) - } else { - w.first = false - } -} -func (w *JSONWriter) Key(k string) { - if w.Write == nil { - return - } - w.Write([]byte(`"`)) - w.Write([]byte(k)) - w.Write([]byte(`":`)) -} -func (w *JSONWriter) KvInt(k string, v int) { - w.Comma() - w.Key(k) - if w.Write != nil { - w.Write([]byte(strconvx.Itoa(v))) - } -} -func (w *JSONWriter) KvStr(k, s string) { - w.Comma() - w.Key(k) - if w.Write == nil { - return - } - w.Write([]byte(`"`)) - for i := 0; i < len(s); i++ { - c := s[i] - switch c { - case '\\', '"': - w.Write([]byte{'\\', c}) - case '\b': - w.Write([]byte{'\\', 'b'}) - case '\f': - w.Write([]byte{'\\', 'f'}) - case '\n': - w.Write([]byte{'\\', 'n'}) - case '\r': - w.Write([]byte{'\\', 'r'}) - case '\t': - w.Write([]byte{'\\', 't'}) - default: - if c < 0x20 { - var buf [6]byte - buf[0], buf[1], buf[2], buf[3] = '\\', 'u', '0', '0' - const hex = "0123456789abcdef" - buf[4] = hex[c>>4] - buf[5] = hex[c&0xF] - w.Write(buf[:]) - } else { - w.Write([]byte{c}) - } - } - } - w.Write([]byte(`"`)) -} +package utilities + +import "devicecode-go/x/strconvx" + +// ----------------------------------------------------------------------------- +// Minimal streaming JSON writer for shmring (no buffers/allocs) +// ----------------------------------------------------------------------------- + +type JSONWriter struct { + Write func([]byte) int + first bool +} + +func (w *JSONWriter) Begin() { + w.first = true + if w.Write != nil { + w.Write([]byte("{")) + } +} +func (w *JSONWriter) End() { + if w.Write != nil { + w.Write([]byte("}\n")) + } +} +func (w *JSONWriter) Comma() { + if w.Write == nil { + return + } + if !w.first { + w.Write([]byte(",")) + } else { + w.first = false + } +} +func (w *JSONWriter) Key(k string) { + if w.Write == nil { + return + } + w.Write([]byte(`"`)) + w.Write([]byte(k)) + w.Write([]byte(`":`)) +} +func (w *JSONWriter) KvInt(k string, v int) { + w.Comma() + w.Key(k) + if w.Write != nil { + w.Write([]byte(strconvx.Itoa(v))) + } +} +func (w *JSONWriter) KvStr(k, s string) { + w.Comma() + w.Key(k) + if w.Write == nil { + return + } + w.Write([]byte(`"`)) + for i := 0; i < len(s); i++ { + c := s[i] + switch c { + case '\\', '"': + w.Write([]byte{'\\', c}) + case '\b': + w.Write([]byte{'\\', 'b'}) + case '\f': + w.Write([]byte{'\\', 'f'}) + case '\n': + w.Write([]byte{'\\', 'n'}) + case '\r': + w.Write([]byte{'\\', 'r'}) + case '\t': + w.Write([]byte{'\\', 't'}) + default: + if c < 0x20 { + var buf [6]byte + buf[0], buf[1], buf[2], buf[3] = '\\', 'u', '0', '0' + const hex = "0123456789abcdef" + buf[4] = hex[c>>4] + buf[5] = hex[c&0xF] + w.Write(buf[:]) + } else { + w.Write([]byte{c}) + } + } + } + w.Write([]byte(`"`)) +} \ No newline at end of file diff --git a/utilities/logger.go b/utilities/logger.go index e2417b1..2fbc29b 100644 --- a/utilities/logger.go +++ b/utilities/logger.go @@ -1,191 +1,191 @@ -package utilities - -import ( - "time" - - "devicecode-go/x/shmring" - "devicecode-go/x/strconvx" -) - -// ----------------------------------------------------------------------------- -// Logger (mirrors to USB console and optionally uart1). No heap churn. -// ----------------------------------------------------------------------------- - -type Logger struct { - target *shmring.Ring - t0 time.Time - LineStart bool - droppedUART1Bytes int // mirror dropped bytes -} - -var nl = [...]byte{'\n'} - -func (l *Logger) SetStart(t time.Time) { l.t0, l.LineStart = t, true } -func (l *Logger) SetUART1(r *shmring.Ring) { l.target = r } - -func (l *Logger) writeString(s string) { - l.writePrefixIfLineStart() - if s != "" { - print(s) - l.logWrite([]byte(s)) - } -} -func (l *Logger) writeBytes(b []byte) { - if len(b) == 0 { - return - } - l.writePrefixIfLineStart() - print(string(b)) - l.logWrite(b) -} -func (l *Logger) writePrefixIfLineStart() { - if !l.LineStart { - return - } - l.LineStart = false - if l.t0.IsZero() { - l.t0 = time.Now() - } - el := time.Since(l.t0) - secs := int(el / time.Second) - ms := int((el % time.Second) / time.Millisecond) // 0..999 - - // Console (no allocations) - print(strconvx.Itoa(secs)) - print(".") - if ms < 100 { - print("0") - } - if ms < 10 { - print("0") - } - print(strconvx.Itoa(ms)) - print(" ") - - // UART1: build once, single write - if l.target != nil { - var buf [20]byte - n := 0 - n += writeDec(buf[:], n, secs) - buf[n] = '.' - n++ - n += writeDecPad3(buf[:], n, ms) - buf[n] = ' ' - n++ - l.logWrite(buf[:n]) - } -} -func writeDecPad3(dst []byte, off int, v int) int { - if v < 0 { - v = 0 - } else if v > 999 { - v = 999 - } - dst[off+0] = byte('0' + (v/100)%10) - dst[off+1] = byte('0' + (v/10)%10) - dst[off+2] = byte('0' + v%10) - return 3 -} -func writeDec(dst []byte, off int, v int) int { - if v == 0 { - dst[off] = '0' - return 1 - } - var tmp [10]byte - j := 0 - for v > 0 { - tmp[j] = byte('0' + v%10) - v /= 10 - j++ - } - i := off - for k := j - 1; k >= 0; k-- { - dst[i] = tmp[k] - i++ - } - return i - off -} -func (l *Logger) writePart(v any) { - switch x := v.(type) { - case string: - l.writeString(x) - case []byte: - l.writeBytes(x) - case int: - l.writeString(strconvx.Itoa(x)) - case int32: - l.writeString(strconvx.Itoa(int(x))) - case int64: - l.writeString(strconvx.Itoa64(x)) - case uint: - l.writeString(strconvx.Itoa(int(x))) - case uint32: - l.writeString(strconvx.Itoa(int(x))) - case uint64: - l.writeString(strconvx.Itoa64(int64(x))) - case bool: - if x { - l.writeString("true") - } else { - l.writeString("false") - } - default: - l.writeString("?") - } -} -func (l *Logger) Print(parts ...any) { - for i := range parts { - l.writePart(parts[i]) - } -} -func (l *Logger) newline() { - print("\n") - l.logWrite(nl[:]) - l.LineStart = true -} -func (l *Logger) Println(parts ...any) { l.Print(parts...); l.newline() } - -func (l *Logger) Deci(label string, deci int) { - l.writePrefixIfLineStart() - if deci < 0 { - l.writeString(label) - l.writeString("-") - deci = -deci - } else { - l.writeString(label) - } - whole := deci / 10 - frac := deci % 10 - l.Println(strconvx.Itoa(whole), ".", strconvx.Itoa(frac)) -} -func (l *Logger) Hundredths(label string, hx100 int) { - l.writePrefixIfLineStart() - if hx100 < 0 { - hx100 = 0 - } - whole := hx100 / 100 - frac := hx100 % 100 - if frac < 10 { - l.Println(label, strconvx.Itoa(whole), ".0", strconvx.Itoa(frac)) - } else { - l.Println(label, strconvx.Itoa(whole), ".", strconvx.Itoa(frac)) - } -} - -// uart1 (logger mirror) — returns bytes written; tracks dropped bytes on partial writes. -func (l *Logger) logWrite(b []byte) int { - if l == nil || l.target == nil || len(b) == 0 { - return 0 - } - n := l.target.TryWriteFrom(b) - if n < len(b) { - l.droppedUART1Bytes += (len(b) - n) - // Avoid recursion; print to console directly. - if l.droppedUART1Bytes == (len(b)-n) || (l.droppedUART1Bytes%1024) == 0 { - print("[uart1] dropped bytes = ") - print(strconvx.Itoa(l.droppedUART1Bytes)) - print("\n") - } - } - return n -} +package utilities + +import ( + "time" + + "devicecode-go/x/shmring" + "devicecode-go/x/strconvx" +) + +// ----------------------------------------------------------------------------- +// Logger (mirrors to USB console and optionally uart1). No heap churn. +// ----------------------------------------------------------------------------- + +type Logger struct { + target *shmring.Ring + t0 time.Time + LineStart bool + droppedUART1Bytes int // mirror dropped bytes +} + +var nl = [...]byte{'\n'} + +func (l *Logger) SetStart(t time.Time) { l.t0, l.LineStart = t, true } +func (l *Logger) SetUART1(r *shmring.Ring) { l.target = r } + +func (l *Logger) writeString(s string) { + l.writePrefixIfLineStart() + if s != "" { + print(s) + l.logWrite([]byte(s)) + } +} +func (l *Logger) writeBytes(b []byte) { + if len(b) == 0 { + return + } + l.writePrefixIfLineStart() + print(string(b)) + l.logWrite(b) +} +func (l *Logger) writePrefixIfLineStart() { + if !l.LineStart { + return + } + l.LineStart = false + if l.t0.IsZero() { + l.t0 = time.Now() + } + el := time.Since(l.t0) + secs := int(el / time.Second) + ms := int((el % time.Second) / time.Millisecond) // 0..999 + + // Console (no allocations) + print(strconvx.Itoa(secs)) + print(".") + if ms < 100 { + print("0") + } + if ms < 10 { + print("0") + } + print(strconvx.Itoa(ms)) + print(" ") + + // UART1: build once, single write + if l.target != nil { + var buf [20]byte + n := 0 + n += writeDec(buf[:], n, secs) + buf[n] = '.' + n++ + n += writeDecPad3(buf[:], n, ms) + buf[n] = ' ' + n++ + l.logWrite(buf[:n]) + } +} +func writeDecPad3(dst []byte, off int, v int) int { + if v < 0 { + v = 0 + } else if v > 999 { + v = 999 + } + dst[off+0] = byte('0' + (v/100)%10) + dst[off+1] = byte('0' + (v/10)%10) + dst[off+2] = byte('0' + v%10) + return 3 +} +func writeDec(dst []byte, off int, v int) int { + if v == 0 { + dst[off] = '0' + return 1 + } + var tmp [10]byte + j := 0 + for v > 0 { + tmp[j] = byte('0' + v%10) + v /= 10 + j++ + } + i := off + for k := j - 1; k >= 0; k-- { + dst[i] = tmp[k] + i++ + } + return i - off +} +func (l *Logger) writePart(v any) { + switch x := v.(type) { + case string: + l.writeString(x) + case []byte: + l.writeBytes(x) + case int: + l.writeString(strconvx.Itoa(x)) + case int32: + l.writeString(strconvx.Itoa(int(x))) + case int64: + l.writeString(strconvx.Itoa64(x)) + case uint: + l.writeString(strconvx.Itoa(int(x))) + case uint32: + l.writeString(strconvx.Itoa(int(x))) + case uint64: + l.writeString(strconvx.Itoa64(int64(x))) + case bool: + if x { + l.writeString("true") + } else { + l.writeString("false") + } + default: + l.writeString("?") + } +} +func (l *Logger) Print(parts ...any) { + for i := range parts { + l.writePart(parts[i]) + } +} +func (l *Logger) newline() { + print("\n") + l.logWrite(nl[:]) + l.LineStart = true +} +func (l *Logger) Println(parts ...any) { l.Print(parts...); l.newline() } + +func (l *Logger) Deci(label string, deci int) { + l.writePrefixIfLineStart() + if deci < 0 { + l.writeString(label) + l.writeString("-") + deci = -deci + } else { + l.writeString(label) + } + whole := deci / 10 + frac := deci % 10 + l.Println(strconvx.Itoa(whole), ".", strconvx.Itoa(frac)) +} +func (l *Logger) Hundredths(label string, hx100 int) { + l.writePrefixIfLineStart() + if hx100 < 0 { + hx100 = 0 + } + whole := hx100 / 100 + frac := hx100 % 100 + if frac < 10 { + l.Println(label, strconvx.Itoa(whole), ".0", strconvx.Itoa(frac)) + } else { + l.Println(label, strconvx.Itoa(whole), ".", strconvx.Itoa(frac)) + } +} + +// uart1 (logger mirror) — returns bytes written; tracks dropped bytes on partial writes. +func (l *Logger) logWrite(b []byte) int { + if l == nil || l.target == nil || len(b) == 0 { + return 0 + } + n := l.target.TryWriteFrom(b) + if n < len(b) { + l.droppedUART1Bytes += (len(b) - n) + // Avoid recursion; print to console directly. + if l.droppedUART1Bytes == (len(b)-n) || (l.droppedUART1Bytes%1024) == 0 { + print("[uart1] dropped bytes = ") + print(strconvx.Itoa(l.droppedUART1Bytes)) + print("\n") + } + } + return n +} \ No newline at end of file diff --git a/x/xxhash/xxhash.go b/x/xxhash/xxhash.go index 9e319c1..ebd517e 100644 --- a/x/xxhash/xxhash.go +++ b/x/xxhash/xxhash.go @@ -3,7 +3,7 @@ // // This package mirrors devicecode-lua/src/shared/hash/xxhash32.lua at // update-migration tip (commit 2c88090). It is used for fabric wire-protocol -// integrity (xfer_begin / xfer_commit checksum field) and for HAL artefact +// integrity (xfer_begin / xfer_commit digest field) and for HAL artefact // hashing. It is not a security primitive. package xxhash From f2d9d60e74cdeb36345994fad2e70b9335f4a951 Mon Sep 17 00:00:00 2001 From: Rich Thanki Date: Tue, 9 Jun 2026 04:25:04 +0100 Subject: [PATCH 09/17] go mod --- go.mod | 4 ++-- go.sum | 2 -- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/go.mod b/go.mod index a7a9bf4..a2a8762 100644 --- a/go.mod +++ b/go.mod @@ -1,11 +1,11 @@ module devicecode-go -go 1.25.0 +go 1.25.1 require ( - pico2-a-b v0.0.0 github.com/jangala-dev/tinygo-uartx v0.0.0-20251028085354-58b6258234b3 golang.org/x/exp v0.0.0-20251002181428-27f1f14c8bb9 + pico2-a-b v0.0.0 tinygo.org/x/drivers v0.33.0 ) diff --git a/go.sum b/go.sum index b2f089d..a00618c 100644 --- a/go.sum +++ b/go.sum @@ -1,7 +1,5 @@ github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4= github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ= -github.com/jangala-dev/tinygo-uartx v0.0.0-20251008020047-bc80b114e3cc h1:HU2VI0lw5wlu1rUgjzSuVH7IWQMNdZEbpDaoxCTVMmY= -github.com/jangala-dev/tinygo-uartx v0.0.0-20251008020047-bc80b114e3cc/go.mod h1:e3HxjGzBZBIsn/oYvWr707ug3IbkglEyivyYVxHRph4= github.com/jangala-dev/tinygo-uartx v0.0.0-20251028085354-58b6258234b3 h1:b6mCDQEeeICoGpsbKyh/kfIRnr2DMK/wACLLi0t8uoU= github.com/jangala-dev/tinygo-uartx v0.0.0-20251028085354-58b6258234b3/go.mod h1:e3HxjGzBZBIsn/oYvWr707ug3IbkglEyivyYVxHRph4= golang.org/x/exp v0.0.0-20251002181428-27f1f14c8bb9 h1:TQwNpfvNkxAVlItJf6Cr5JTsVZoC/Sj7K3OZv2Pc14A= From 30fcbfd56b35e9ac22d0b355e5abb4b851bc373b Mon Sep 17 00:00:00 2001 From: Rich Thanki Date: Wed, 10 Jun 2026 14:44:34 +0100 Subject: [PATCH 10/17] ready for hardware testing --- cmd/pico-cm5-emulator/README.md | 109 +++ cmd/pico-cm5-emulator/chunk_1024.go | 8 + cmd/pico-cm5-emulator/chunk_2048.go | 6 + cmd/pico-cm5-emulator/chunk_256.go | 8 + cmd/pico-cm5-emulator/chunk_default.go | 8 + cmd/pico-cm5-emulator/led_rp.go | 36 + cmd/pico-cm5-emulator/led_stub.go | 9 + cmd/pico-cm5-emulator/main.go | 701 ++++++++++++++++++ cmd/pico-cm5-emulator/main_test.go | 101 +++ cmd/pico-cm5-emulator/payload_200k.go | 5 + cmd/pico-cm5-emulator/payload_default.go | 5 + cmd/pico-cm5-emulator/trace_disabled.go | 5 + cmd/pico-cm5-emulator/trace_enabled.go | 5 + docs/colleague-hardware-test-plan.md | 159 ---- docs/fabric-update-gates.md | 81 -- docs/test-plan.md | 344 +++++++++ docs/uartx-probe.md | 54 ++ go.mod | 2 + go.sum | 2 - original.zip | Bin 210046 -> 0 bytes services/fabric/fabric_test.go | 45 ++ services/fabric/session.go | 178 +++-- services/fabric/transfer.go | 443 +++++++---- services/fabric/transfer_sink.go | 27 + services/fabric/xfer_probe_disabled.go | 7 + services/fabric/xfer_probe_enabled.go | 87 +++ services/hal/devices/serial_raw/builder.go | 77 +- .../devices/serial_raw/uartx_probe_default.go | 16 + .../devices/serial_raw/uartx_probe_enabled.go | 117 +++ services/hal/internal/core/resources.go | 30 + .../hal/internal/provider/rp2_resources.go | 20 +- services/hal/internal/provider/setup_none.go | 2 +- .../hal/internal/provider/setup_selected.go | 2 +- .../provider/setups/pico_bb_proto_1.go | 13 +- .../provider/setups/pico_cm5_emulator.go | 34 + .../internal/provider/setups/pico_rich_dev.go | 13 +- services/otadiag/otadiag.go | 7 + services/otadiag/verbose_trace.go | 7 + services/updater/stream_lease.go | 63 ++ services/updater/trace_disabled.go | 5 + services/updater/trace_enabled.go | 5 + third_party/tinygo-uartx/README.md | 239 ------ third_party/tinygo-uartx/uartx/rp2_uart.go | 172 +++-- 43 files changed, 2501 insertions(+), 756 deletions(-) create mode 100644 cmd/pico-cm5-emulator/README.md create mode 100644 cmd/pico-cm5-emulator/chunk_1024.go create mode 100644 cmd/pico-cm5-emulator/chunk_2048.go create mode 100644 cmd/pico-cm5-emulator/chunk_256.go create mode 100644 cmd/pico-cm5-emulator/chunk_default.go create mode 100644 cmd/pico-cm5-emulator/led_rp.go create mode 100644 cmd/pico-cm5-emulator/led_stub.go create mode 100644 cmd/pico-cm5-emulator/main.go create mode 100644 cmd/pico-cm5-emulator/main_test.go create mode 100644 cmd/pico-cm5-emulator/payload_200k.go create mode 100644 cmd/pico-cm5-emulator/payload_default.go create mode 100644 cmd/pico-cm5-emulator/trace_disabled.go create mode 100644 cmd/pico-cm5-emulator/trace_enabled.go delete mode 100644 docs/colleague-hardware-test-plan.md delete mode 100644 docs/fabric-update-gates.md create mode 100644 docs/test-plan.md create mode 100644 docs/uartx-probe.md delete mode 100644 original.zip create mode 100644 services/fabric/xfer_probe_disabled.go create mode 100644 services/fabric/xfer_probe_enabled.go create mode 100644 services/hal/devices/serial_raw/uartx_probe_default.go create mode 100644 services/hal/devices/serial_raw/uartx_probe_enabled.go create mode 100644 services/hal/internal/provider/setups/pico_cm5_emulator.go create mode 100644 services/otadiag/verbose_trace.go create mode 100644 services/updater/trace_disabled.go create mode 100644 services/updater/trace_enabled.go delete mode 100644 third_party/tinygo-uartx/README.md diff --git a/cmd/pico-cm5-emulator/README.md b/cmd/pico-cm5-emulator/README.md new file mode 100644 index 0000000..57f55e4 --- /dev/null +++ b/cmd/pico-cm5-emulator/README.md @@ -0,0 +1,109 @@ +# Pico CM5 emulator + +This command builds a very small Pico 1 firmware that behaves like a CM5-side +Fabric peer for hardware bring-up. It is intended for the two-Pico setup: + +```text +Pico 1 UART0 TX GP0 -> Pico 2 UART1 RX GP5 +Pico 1 UART0 RX GP1 <- Pico 2 UART1 TX GP4 +Pico 1 GND <-> Pico 2 GND +``` + +Do not connect 3V3 or VSYS between the boards unless you are deliberately +powering one board from the other. + +## Pico 2 under test + +For the first physical UART protocol test, flash the Pico 2 with the hwtest +staging backend: + +```sh +tinygo flash -stack-size=3KB -monitor -scheduler tasks \ + -target=pico2 -tags "pico_bb_proto_1 fabric_uart_hwtest" main.go +``` + +This keeps production flash/apply disabled and stages into the safe digest/count +backend. + +## Pico 1 emulator + +Flash the Pico 1 with: + +```sh +tinygo flash -stack-size=3KB -monitor -scheduler tasks \ + -target=pico -tags "pico_cm5_emulator" ./cmd/pico-cm5-emulator +``` + +The emulator opens UART0 at 115200 baud, sends a Fabric hello, calls +`cap/self/updater/main/rpc/prepare-update`, transfers a deterministic 1024-byte +blob to `updater/main`, commits the transfer, and waits for `xfer_done`. + +When monitored over USB it prints each major phase. When running headless, the +onboard LED gives status: + +```text +fast blink failure +mostly-on blink pass / alive +``` + +This test does not send `commit-update` and cannot reboot the Pico 2. + +### Timing note + +The emulator uses a 180 second end-to-end script timeout. On the full Pico 2 +appliance image the first `prepare-update` can be delayed by HAL and retained +state publication, so a shorter timeout can expire just as the transfer phase +begins. The emulator logs `prepare-update sent`, `xfer_begin sent`, +`xfer_ready received`, and `xfer_need next=0 received` to make it clear which +phase is blocking. + +### JSONL reader note + +The emulator reader treats UART as a byte stream, not as one read per line. It +only releases bytes from the RX ring up to the newline that completed the line. +If the same UART read span also contains the start of the next JSONL frame, +those bytes remain in the ring and are consumed by the next `readLine` call. +This is important because the reactive UART path can legitimately deliver +`...\n{` in one readable span when the peer is sending frames back-to-back. + +### Chunk-size tags + +By default the emulator uses 2048-byte chunks, matching the current advertised +`max_chunk_size` used by the MCU updater prepare response. This is the normal +large-transfer test shape: + +```sh +tinygo flash -stack-size=8KB -monitor -scheduler tasks \ + -target=pico -tags "pico_cm5_emulator pico_cm5_payload_200k" \ + ./cmd/pico-cm5-emulator +``` + +Use 1024-byte chunks for an intermediate setting: + +```sh +tinygo flash -stack-size=8KB -monitor -scheduler tasks \ + -target=pico -tags "pico_cm5_emulator pico_cm5_payload_200k pico_cm5_chunk_1024" \ + ./cmd/pico-cm5-emulator +``` + +Use 256-byte chunks as a stop-and-wait stress test: + +```sh +tinygo flash -stack-size=8KB -monitor -scheduler tasks \ + -target=pico -tags "pico_cm5_emulator pico_cm5_payload_200k pico_cm5_chunk_256" \ + ./cmd/pico-cm5-emulator +``` + +### MCU transfer probe + +For a targeted MCU-side transfer trace without the full `fabric_trace` frame dump, +flash the Pico 2 with `fabric_xfer_probe`: + +```sh +tinygo flash -stack-size=8KB -monitor -scheduler tasks \ + -target=pico2 -tags "pico_bb_proto_1 fabric_uart_hwtest fabric_xfer_probe" main.go +``` + +The probe logs chunk receive offsets, write start/done, receiver retries, +digest/decode errors, stale/future chunks and commit start/done. It is intended +to explain receiver-driven retries during large transfers. diff --git a/cmd/pico-cm5-emulator/chunk_1024.go b/cmd/pico-cm5-emulator/chunk_1024.go new file mode 100644 index 0000000..95be6bf --- /dev/null +++ b/cmd/pico-cm5-emulator/chunk_1024.go @@ -0,0 +1,8 @@ +//go:build pico_cm5_chunk_1024 && !pico_cm5_chunk_256 + +package main + +const ( + chunkSize = 1024 + chunkBase64Max = 1536 +) diff --git a/cmd/pico-cm5-emulator/chunk_2048.go b/cmd/pico-cm5-emulator/chunk_2048.go new file mode 100644 index 0000000..7ad655c --- /dev/null +++ b/cmd/pico-cm5-emulator/chunk_2048.go @@ -0,0 +1,6 @@ +//go:build ignore + +package main + +// The emulator's default chunk size is now 2048. This file is intentionally +// ignored and kept only to make older references to pico_cm5_chunk_2048 obvious. diff --git a/cmd/pico-cm5-emulator/chunk_256.go b/cmd/pico-cm5-emulator/chunk_256.go new file mode 100644 index 0000000..e6160fc --- /dev/null +++ b/cmd/pico-cm5-emulator/chunk_256.go @@ -0,0 +1,8 @@ +//go:build pico_cm5_chunk_256 + +package main + +const ( + chunkSize = 256 + chunkBase64Max = 512 +) diff --git a/cmd/pico-cm5-emulator/chunk_default.go b/cmd/pico-cm5-emulator/chunk_default.go new file mode 100644 index 0000000..ad494fb --- /dev/null +++ b/cmd/pico-cm5-emulator/chunk_default.go @@ -0,0 +1,8 @@ +//go:build !pico_cm5_chunk_256 && !pico_cm5_chunk_1024 + +package main + +const ( + chunkSize = 2048 + chunkBase64Max = 3072 +) diff --git a/cmd/pico-cm5-emulator/led_rp.go b/cmd/pico-cm5-emulator/led_rp.go new file mode 100644 index 0000000..7b39133 --- /dev/null +++ b/cmd/pico-cm5-emulator/led_rp.go @@ -0,0 +1,36 @@ +//go:build tinygo && (rp2040 || rp2350) + +package main + +import ( + "machine" + "time" +) + +var statusLED = machine.LED + +func ledInit() { + statusLED.Configure(machine.PinConfig{Mode: machine.PinOutput}) + statusLED.Low() +} + +func ledOn() { statusLED.High() } +func ledOff() { statusLED.Low() } + +func ledPassLoop() { + for { + statusLED.High() + time.Sleep(1800 * time.Millisecond) + statusLED.Low() + time.Sleep(200 * time.Millisecond) + } +} + +func ledFailLoop() { + for { + statusLED.High() + time.Sleep(120 * time.Millisecond) + statusLED.Low() + time.Sleep(120 * time.Millisecond) + } +} diff --git a/cmd/pico-cm5-emulator/led_stub.go b/cmd/pico-cm5-emulator/led_stub.go new file mode 100644 index 0000000..9dcce88 --- /dev/null +++ b/cmd/pico-cm5-emulator/led_stub.go @@ -0,0 +1,9 @@ +//go:build !tinygo || !(rp2040 || rp2350) + +package main + +func ledInit() {} +func ledOn() {} +func ledOff() {} +func ledPassLoop() { select {} } +func ledFailLoop() { select {} } diff --git a/cmd/pico-cm5-emulator/main.go b/cmd/pico-cm5-emulator/main.go new file mode 100644 index 0000000..19f1f98 --- /dev/null +++ b/cmd/pico-cm5-emulator/main.go @@ -0,0 +1,701 @@ +package main + +import ( + "context" + "encoding/base64" + "errors" + "runtime" + "strconv" + "time" + + "devicecode-go/bus" + "devicecode-go/services/hal" + "devicecode-go/types" + "devicecode-go/x/shmring" + "devicecode-go/x/xxhash" +) + +const ( + linkUART = "uart0" + testTimeout = 180 * time.Second + chunkAckTimeout = 750 * time.Millisecond + maxChunkResends = 32 + cm5SIDPrefix = "pico-cm5-emulator" +) + +var chunkScratch [chunkSize]byte +var lineScratch [4096]byte +var b64Scratch [chunkBase64Max]byte + +type peer struct { + rx *shmring.Ring + tx *shmring.Ring + n int + sid string + prepareID string + jobID string + xferID string +} + +func main() { + ledInit() + ledOff() + time.Sleep(3 * time.Second) + println("0.000 [pico-cm5] bootstrapping bus + HAL") + + ctx := context.Background() + b := bus.NewBus(4, "+", "#") + halConn := b.NewConnection("hal") + ctlConn := b.NewConnection("pico-cm5") + go hal.Run(ctx, halConn) + + time.Sleep(250 * time.Millisecond) + opened, err := openSerial(ctx, ctlConn, linkUART, 512, 512) + if err != nil { + fail("serial open failed", err) + } + rx := shmring.Get(shmring.Handle(opened.RXHandle)) + tx := shmring.Get(shmring.Handle(opened.TXHandle)) + if rx == nil || tx == nil { + fail("serial ring resolution failed", errors.New("nil_ring")) + } + println("0.000 [pico-cm5] uart0 opened; starting Fabric CM5-emulator script") + + ledOn() + runID := makeRunID() + p := &peer{ + rx: rx, + tx: tx, + sid: cm5SIDPrefix + "-sid-" + runID, + prepareID: "pico-cm5-prepare-" + runID, + jobID: "pico-cm5-job-" + runID, + xferID: "pico-cm5-xfer-" + runID, + } + println("0.000 [pico-cm5] session sid=", p.sid, "xfer=", p.xferID) + if err := p.run(ctx); err != nil { + fail("fabric script failed", err) + } + println("0.000 [pico-cm5] PASS: Fabric prepare + transfer completed") + for { + ledOn() + printMem() + time.Sleep(1800 * time.Millisecond) + ledOff() + time.Sleep(200 * time.Millisecond) + } +} + +func fail(msg string, err error) { + println("0.000 [pico-cm5] FAIL:", msg, err.Error()) + for { + ledOn() + time.Sleep(120 * time.Millisecond) + ledOff() + time.Sleep(120 * time.Millisecond) + } +} + +func (p *peer) run(parent context.Context) error { + ctx, cancel := context.WithTimeout(parent, testTimeout) + defer cancel() + + digest := payloadDigest(payloadSize) + println("0.000 [pico-cm5] payload bytes=", payloadSize, "chunk=", chunkSize, "digest=", digest) + + if err := p.writeHello(ctx); err != nil { + return err + } + println("0.000 [pico-cm5] hello sent") + if _, err := p.waitType(ctx, "hello_ack", ""); err != nil { + return err + } + println("0.000 [pico-cm5] hello_ack received") + + if err := p.writePrepare(ctx, p.prepareID); err != nil { + return err + } + println("0.000 [pico-cm5] prepare-update sent") + if err := p.waitReplyOK(ctx, p.prepareID); err != nil { + return err + } + println("0.000 [pico-cm5] prepare-update ok") + + cm5TraceEvent("phase_xfer_begin_write") + if err := p.writeXferBegin(ctx, p.xferID, uint32(payloadSize), digest); err != nil { + return err + } + println("0.000 [pico-cm5] xfer_begin sent") + cm5TraceEvent("phase_wait_xfer_ready") + if _, err := p.waitType(ctx, "xfer_ready", p.xferID); err != nil { + return err + } + println("0.000 [pico-cm5] xfer_ready received") + cm5TraceEvent("phase_wait_xfer_need_0") + if err := p.transferPayload(ctx, p.xferID); err != nil { + return err + } + cm5TraceEvent("phase_commit_write") + if err := p.writeXferCommit(ctx, p.xferID, uint32(payloadSize), digest); err != nil { + return err + } + if _, err := p.waitType(ctx, "xfer_done", p.xferID); err != nil { + return err + } + println("0.000 [pico-cm5] xfer_done") + return nil +} + +func (p *peer) transferPayload(ctx context.Context, id string) error { + maxSentEnd := uint32(0) + lastAck := uint32(0) + lastSentOff := uint32(0) + haveOutstanding := false + resendsWithoutProgress := 0 + + for { + need, err := p.waitNeedWithTimeout(ctx, id, chunkAckTimeout) + if err != nil { + if errors.Is(err, context.DeadlineExceeded) && haveOutstanding { + resendsWithoutProgress++ + println("0.000 [pico-cm5] ack timeout resend offset=", int(lastSentOff), "retry=", resendsWithoutProgress) + if resendsWithoutProgress > maxChunkResends { + return errors.New("too_many_ack_timeouts_at_offset:" + strconv.FormatUint(uint64(lastSentOff), 10)) + } + if err := p.sendPayloadChunk(ctx, id, lastSentOff); err != nil { + return err + } + continue + } + return err + } + if need > uint32(payloadSize) { + return errors.New("bad_xfer_need_next_too_large:" + strconv.FormatUint(uint64(need), 10)) + } + if need == uint32(payloadSize) { + println("0.000 [pico-cm5] chunk ack next=", int(need)) + return nil + } + if need > maxSentEnd { + return errors.New("bad_xfer_need_future:" + strconv.FormatUint(uint64(need), 10) + ":max_sent=" + strconv.FormatUint(uint64(maxSentEnd), 10)) + } + + if need > lastAck { + lastAck = need + resendsWithoutProgress = 0 + haveOutstanding = false + if shouldPrintAck(need) { + println("0.000 [pico-cm5] chunk ack next=", int(need)) + } + } else if need < maxSentEnd { + resendsWithoutProgress++ + println("0.000 [pico-cm5] retry need next=", int(need), "max_sent=", int(maxSentEnd), "retry=", resendsWithoutProgress) + if resendsWithoutProgress > maxChunkResends { + return errors.New("too_many_retries_at_older_offset:" + strconv.FormatUint(uint64(need), 10)) + } + } + + if err := p.sendPayloadChunk(ctx, id, need); err != nil { + return err + } + lastSentOff = need + haveOutstanding = true + end := need + uint32(chunkSize) + if end > uint32(payloadSize) { + end = uint32(payloadSize) + } + if end > maxSentEnd { + maxSentEnd = end + } + } +} + +func (p *peer) sendPayloadChunk(ctx context.Context, id string, off uint32) error { + end := int(off) + chunkSize + if end > payloadSize { + end = payloadSize + } + chunk := makePayloadChunk(int(off), chunkScratch[:end-int(off)]) + cm5TraceEventKV("phase_chunk_write", "offset", strconv.Itoa(int(off))) + return p.writeXferChunk(ctx, id, off, chunk) +} + +func shouldPrintAck(next uint32) bool { + if payloadSize <= 4096 { + return true + } + return next != 0 && (next%4096 == 0 || next == uint32(payloadSize)) +} + +func makeRunID() string { + // The SID is a session identifier, not a stable node identity. Make it + // change across emulator resets so the MCU can distinguish a fresh CM5 + // session from a duplicate hello in an existing session, and abort any + // old in-flight transfer state accordingly. + n := uint64(time.Now().UnixNano()) + var ms runtime.MemStats + runtime.ReadMemStats(&ms) + n ^= uint64(ms.Mallocs) << 17 + n ^= uint64(ms.Alloc) << 1 + if n == 0 { + n = 1 + } + return strconv.FormatUint(n, 16) +} + +func openSerial(ctx context.Context, conn *bus.Connection, name string, rxSize, txSize int) (types.SerialSessionOpened, error) { + evT := bus.T("hal", "cap", "io", "serial", name, "event", "session_opened") + sub := conn.Subscribe(evT) + defer conn.Unsubscribe(sub) + + ctrlT := bus.T("hal", "cap", "io", "serial", name, "control", "session_open") + reqCtx, cancel := context.WithTimeout(ctx, 2*time.Second) + defer cancel() + if _, err := conn.RequestWait(reqCtx, conn.NewMessage(ctrlT, types.SerialSessionOpen{RXSize: rxSize, TXSize: txSize}, false)); err != nil { + return types.SerialSessionOpened{}, err + } + for { + select { + case m := <-sub.Channel(): + if rep, ok := m.Payload.(types.SerialSessionOpened); ok { + return rep, nil + } + case <-reqCtx.Done(): + return types.SerialSessionOpened{}, reqCtx.Err() + } + } +} + +func (p *peer) writeHello(ctx context.Context) error { + b := make([]byte, 0, 96) + b = append(b, `{"type":"hello","proto":"fabric-jsonl/1","sid":"`...) + b = appendJSONString(b, p.sid) + b = append(b, `","node":"bigbox-cm5"}`...) + return p.writeLine(ctx, b) +} + +func (p *peer) writePrepare(ctx context.Context, id string) error { + b := make([]byte, 0, 192) + b = append(b, `{"type":"call","id":"`...) + b = appendJSONString(b, id) + b = append(b, `","topic":["cap","self","updater","main","rpc","prepare-update"],"payload":{"job_id":"`...) + b = appendJSONString(b, p.jobID) + b = append(b, `","target":"mcu","expected_image_id":"pico-cm5-hwtest-image"}}`...) + return p.writeLine(ctx, b) +} + +func (p *peer) writeXferBegin(ctx context.Context, id string, size uint32, digest string) error { + b := make([]byte, 0, 192) + b = append(b, `{"type":"xfer_begin","xfer_id":"`...) + b = appendJSONString(b, id) + b = append(b, `","target":"updater/main","size":`...) + b = strconv.AppendUint(b, uint64(size), 10) + b = append(b, `,"digest_alg":"xxhash32","digest":"`...) + b = appendJSONString(b, digest) + b = append(b, `","meta":{"source":"pico-cm5-emulator"}}`...) + return p.writeLine(ctx, b) +} + +func (p *peer) writeXferChunk(ctx context.Context, id string, off uint32, chunk []byte) error { + n := base64.RawURLEncoding.EncodedLen(len(chunk)) + if n > len(b64Scratch) { + return errors.New("chunk_too_large") + } + base64.RawURLEncoding.Encode(b64Scratch[:n], chunk) + chunkDigest := hex8(xxhash.Sum32(chunk, 0)) + b := make([]byte, 0, 160+n) + b = append(b, `{"type":"xfer_chunk","xfer_id":"`...) + b = appendJSONString(b, id) + b = append(b, `","offset":`...) + b = strconv.AppendUint(b, uint64(off), 10) + b = append(b, `,"data":"`...) + b = append(b, b64Scratch[:n]...) + b = append(b, `","chunk_digest":"`...) + b = appendJSONString(b, chunkDigest) + b = append(b, `"}`...) + return p.writeLine(ctx, b) +} + +func (p *peer) writeXferCommit(ctx context.Context, id string, size uint32, digest string) error { + b := make([]byte, 0, 144) + b = append(b, `{"type":"xfer_commit","xfer_id":"`...) + b = appendJSONString(b, id) + b = append(b, `","size":`...) + b = strconv.AppendUint(b, uint64(size), 10) + b = append(b, `,"digest_alg":"xxhash32","digest":"`...) + b = appendJSONString(b, digest) + b = append(b, `"}`...) + return p.writeLine(ctx, b) +} + +func (p *peer) writePong(ctx context.Context, sid string) error { + b := make([]byte, 0, 64) + b = append(b, `{"type":"pong","sid":"`...) + b = appendJSONString(b, sid) + b = append(b, `"}`...) + return p.writeLine(ctx, b) +} + +func (p *peer) waitReplyOK(ctx context.Context, id string) error { + for { + line, err := p.readLine(ctx) + if err != nil { + return err + } + t := topString(line, "type") + switch t { + case "reply": + if topString(line, "id") != id { + continue + } + ok, seen := topBool(line, "ok") + if seen && ok { + return nil + } + return errors.New("reply_error:" + topString(line, "err")) + case "ping": + _ = p.writePong(ctx, topString(line, "sid")) + case "pub": + logPub(line) + default: + if t != "" { + println("0.000 [pico-cm5] rx", t) + } + } + } +} + +func (p *peer) waitType(ctx context.Context, wantType, wantXfer string) ([]byte, error) { + for { + line, err := p.readLine(ctx) + if err != nil { + return nil, err + } + t := topString(line, "type") + switch t { + case wantType: + if wantXfer == "" || topString(line, "xfer_id") == wantXfer { + return line, nil + } + case "xfer_abort": + if wantXfer == "" || topString(line, "xfer_id") == wantXfer { + return nil, errors.New("xfer_abort:" + topString(line, "err")) + } + case "reply": + if errText := topString(line, "err"); errText != "" { + println("0.000 [pico-cm5] stray reply err=", errText) + } + case "ping": + _ = p.writePong(ctx, topString(line, "sid")) + case "pub": + logPub(line) + default: + if t != "" { + println("0.000 [pico-cm5] rx", t) + } + } + } +} + +func (p *peer) waitNeed(ctx context.Context, id string) (uint32, error) { + for { + line, err := p.waitType(ctx, "xfer_need", id) + if err != nil { + return 0, err + } + got, ok := topUint(line, "next") + if ok { + return got, nil + } + return 0, errors.New("bad_xfer_need_missing_next:" + cm5TracePreview(line)) + } +} + +func (p *peer) waitNeedWithTimeout(ctx context.Context, id string, d time.Duration) (uint32, error) { + waitCtx, cancel := context.WithTimeout(ctx, d) + defer cancel() + return p.waitNeed(waitCtx, id) +} + +func (p *peer) writeLine(ctx context.Context, b []byte) error { + if len(b) == 0 || b[len(b)-1] != '\n' { + b = append(b, '\n') + } + cm5TraceFrame("tx", b) + off := 0 + for off < len(b) { + if n := p.tx.TryWriteFrom(b[off:]); n > 0 { + off += n + continue + } + select { + case <-ctx.Done(): + return ctx.Err() + case <-p.tx.Writable(): + } + } + return nil +} + +func (p *peer) readLine(ctx context.Context) ([]byte, error) { + for { + span, _ := p.rx.ReadAcquire() + if len(span) > 0 { + line, consumed, ok, err := p.consumeLineSpan(span) + if consumed > 0 { + p.rx.ReadRelease(consumed) + } + if err != nil { + return nil, err + } + if ok { + return line, nil + } + continue + } + select { + case <-ctx.Done(): + return nil, ctx.Err() + case <-p.rx.Readable(): + } + } +} + +func (p *peer) consumeLineSpan(span []byte) (line []byte, consumed int, ok bool, err error) { + for i, c := range span { + consumed = i + 1 + if c == '\r' { + continue + } + if c == '\n' { + if p.n == 0 { + continue + } + line := lineScratch[:p.n] + cm5TraceFrame("rx", line) + p.n = 0 + return line, consumed, true, nil + } + if p.n >= len(lineScratch) { + p.n = 0 + return nil, consumed, false, errors.New("line_too_long") + } + lineScratch[p.n] = c + p.n++ + } + return nil, consumed, false, nil +} + +func payloadByteAt(i int) byte { + return byte((i*37 + 11) & 0xff) +} + +func makePayloadChunk(off int, dst []byte) []byte { + for i := range dst { + dst[i] = payloadByteAt(off + i) + } + return dst +} + +func payloadDigest(size int) string { + h := xxhash.New(0) + var buf [chunkSize]byte + for off := 0; off < size; off += chunkSize { + end := off + chunkSize + if end > size { + end = size + } + _, _ = h.Write(makePayloadChunk(off, buf[:end-off])) + } + return hex8(h.Sum32()) +} + +func hex8(v uint32) string { + const h = "0123456789abcdef" + var b [8]byte + for i := 7; i >= 0; i-- { + b[i] = h[v&0xf] + v >>= 4 + } + return string(b[:]) +} + +func appendJSONString(b []byte, s string) []byte { + for i := 0; i < len(s); i++ { + c := s[i] + switch c { + case '\\', '"': + b = append(b, '\\', c) + case '\n': + b = append(b, '\\', 'n') + case '\r': + b = append(b, '\\', 'r') + case '\t': + b = append(b, '\\', 't') + default: + b = append(b, c) + } + } + return b +} + +func findKey(line []byte, key string) int { + // Locate a top-level-ish JSON field by string pattern. The Fabric frames used + // by this emulator are compact and do not contain the same field names inside + // escaped strings, which is sufficient for this smoke firmware. + patLen := len(key) + 2 + for i := 0; i+patLen < len(line); i++ { + if line[i] != '"' { + continue + } + if string(line[i+1:i+1+len(key)]) != key || line[i+1+len(key)] != '"' { + continue + } + j := i + patLen + for j < len(line) && (line[j] == ' ' || line[j] == '\t') { + j++ + } + if j < len(line) && line[j] == ':' { + j++ + for j < len(line) && (line[j] == ' ' || line[j] == '\t') { + j++ + } + return j + } + } + return -1 +} + +func topString(line []byte, key string) string { + i := findKey(line, key) + if i < 0 || i >= len(line) || line[i] != '"' { + return "" + } + i++ + start := i + for i < len(line) { + if line[i] == '\\' { + i += 2 + continue + } + if line[i] == '"' { + return string(line[start:i]) + } + i++ + } + return "" +} + +func topBool(line []byte, key string) (bool, bool) { + i := findKey(line, key) + if i < 0 || i >= len(line) { + return false, false + } + if i+4 <= len(line) && string(line[i:i+4]) == "true" { + return true, true + } + if i+5 <= len(line) && string(line[i:i+5]) == "false" { + return false, true + } + return false, false +} + +func topUint(line []byte, key string) (uint32, bool) { + i := findKey(line, key) + if i < 0 || i >= len(line) || line[i] < '0' || line[i] > '9' { + return 0, false + } + var v uint32 + for i < len(line) && line[i] >= '0' && line[i] <= '9' { + v = v*10 + uint32(line[i]-'0') + i++ + } + return v, true +} + +func logPub(line []byte) { + // Keep pub logging light. Detailed frame logs are intentionally omitted so the + // emulator remains closer to the 3 KB stack target. + if topic := topString(line, "topic"); topic != "" { + println("0.000 [pico-cm5] pub", topic) + } +} + +func cm5TraceFrame(dir string, b []byte) { + if !picoCM5TraceEnabled { + return + } + line := b + if len(line) > 0 && line[len(line)-1] == '\n' { + line = line[:len(line)-1] + } + println( + "0.000 [pico-cm5-trace]", dir, + "type", topString(line, "type"), + "xfer", topString(line, "xfer_id"), + "id", topString(line, "id"), + "next", traceUint(line, "next"), + "len", len(line), + "line", cm5TracePreview(line), + ) +} + +func cm5TraceEvent(event string) { + if !picoCM5TraceEnabled { + return + } + println("0.000 [pico-cm5-trace]", event) +} + +func cm5TraceEventKV(event, key, value string) { + if !picoCM5TraceEnabled { + return + } + println("0.000 [pico-cm5-trace]", event, key, value) +} + +func cm5TracePreview(data []byte) string { + const max = 220 + if len(data) > max { + data = data[:max] + } + out := make([]byte, 0, len(data)*2+3) + for _, c := range data { + switch c { + case '\n': + out = append(out, '\\', 'n') + case '\r': + out = append(out, '\\', 'r') + case '\t': + out = append(out, '\\', 't') + default: + if c < 0x20 || c > 0x7e { + out = append(out, '\\', 'x', hexNibble(c>>4), hexNibble(c)) + } else { + out = append(out, c) + } + } + } + if len(data) == max { + out = append(out, '.', '.', '.') + } + return string(out) +} + +func traceUint(line []byte, key string) uint32 { + v, _ := topUint(line, key) + return v +} + +func hexNibble(v byte) byte { + v &= 0x0f + if v < 10 { + return '0' + v + } + return 'a' + (v - 10) +} + +func printMem() { + var m runtime.MemStats + runtime.ReadMemStats(&m) + println("0.000 [pico-cm5] mem alloc:", int(m.Alloc), "heapSys:", int(m.HeapSys), "mallocs:", int(m.Mallocs), "frees:", int(m.Frees)) +} diff --git a/cmd/pico-cm5-emulator/main_test.go b/cmd/pico-cm5-emulator/main_test.go new file mode 100644 index 0000000..bf4ba97 --- /dev/null +++ b/cmd/pico-cm5-emulator/main_test.go @@ -0,0 +1,101 @@ +package main + +import ( + "context" + "testing" + "time" + + "devicecode-go/x/shmring" + "devicecode-go/x/xxhash" +) + +func TestReadLinePreservesBytesAfterNewline(t *testing.T) { + rx := shmring.New(1024) + p := &peer{rx: rx} + input := []byte("{\"type\":\"pub\"}\n{\"type\":\"xfer_need\",\"xfer_id\":\"x\",\"next\":0}\n") + if n := rx.TryWriteFrom(input); n != len(input) { + t.Fatalf("write = %d, want %d", n, len(input)) + } + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + + line, err := p.readLine(ctx) + if err != nil { + t.Fatalf("read first line: %v", err) + } + if got, want := string(line), "{\"type\":\"pub\"}"; got != want { + t.Fatalf("first line = %q, want %q", got, want) + } + + line, err = p.readLine(ctx) + if err != nil { + t.Fatalf("read second line: %v", err) + } + want := "{\"type\":\"xfer_need\",\"xfer_id\":\"x\",\"next\":0}" + if got := string(line); got != want { + t.Fatalf("second line = %q, want %q", got, want) + } +} + +func TestReadLinePreservesBytesAfterNewlineAcrossRingWrap(t *testing.T) { + rx := shmring.New(64) + p := &peer{rx: rx} + pad := []byte("012345678901234567890123456789012345678901234567") + if n := rx.TryWriteFrom(pad); n != len(pad) { + t.Fatalf("pad write = %d", n) + } + var discard [64]byte + if n := rx.TryReadInto(discard[:len(pad)]); n != len(pad) { + t.Fatalf("pad read = %d", n) + } + input := []byte("{\"type\":\"a\"}\n{\"type\":\"b\"}\n") + if n := rx.TryWriteFrom(input); n != len(input) { + t.Fatalf("write = %d, want %d", n, len(input)) + } + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + + line, err := p.readLine(ctx) + if err != nil { + t.Fatalf("read first line: %v", err) + } + if got, want := string(line), "{\"type\":\"a\"}"; got != want { + t.Fatalf("first line = %q, want %q", got, want) + } + line, err = p.readLine(ctx) + if err != nil { + t.Fatalf("read second line: %v", err) + } + if got, want := string(line), "{\"type\":\"b\"}"; got != want { + t.Fatalf("second line = %q, want %q", got, want) + } +} + +func TestPayloadDigestMatchesMaterialisedPayload(t *testing.T) { + for _, size := range []int{0, 1, 15, 16, 17, 255, 256, 257, 1024, 4097} { + buf := make([]byte, size) + for off := 0; off < size; off += chunkSize { + end := off + chunkSize + if end > size { + end = size + } + makePayloadChunk(off, buf[off:end]) + } + got := payloadDigest(size) + want := hex8(xxhash.Sum32(buf, 0)) + if got != want { + t.Fatalf("size %d digest = %s, want %s", size, got, want) + } + } +} + +func TestPayloadChunkMatchesGeneratorOffset(t *testing.T) { + var buf [17]byte + chunk := makePayloadChunk(251, buf[:]) + for i, got := range chunk { + want := payloadByteAt(251 + i) + if got != want { + t.Fatalf("byte %d = %d, want %d", i, got, want) + } + } +} diff --git a/cmd/pico-cm5-emulator/payload_200k.go b/cmd/pico-cm5-emulator/payload_200k.go new file mode 100644 index 0000000..5ac074a --- /dev/null +++ b/cmd/pico-cm5-emulator/payload_200k.go @@ -0,0 +1,5 @@ +//go:build pico_cm5_payload_200k + +package main + +const payloadSize = 200 * 1024 diff --git a/cmd/pico-cm5-emulator/payload_default.go b/cmd/pico-cm5-emulator/payload_default.go new file mode 100644 index 0000000..517ae6d --- /dev/null +++ b/cmd/pico-cm5-emulator/payload_default.go @@ -0,0 +1,5 @@ +//go:build !pico_cm5_payload_200k + +package main + +const payloadSize = 1024 diff --git a/cmd/pico-cm5-emulator/trace_disabled.go b/cmd/pico-cm5-emulator/trace_disabled.go new file mode 100644 index 0000000..e698ba4 --- /dev/null +++ b/cmd/pico-cm5-emulator/trace_disabled.go @@ -0,0 +1,5 @@ +//go:build !pico_cm5_trace + +package main + +const picoCM5TraceEnabled = false diff --git a/cmd/pico-cm5-emulator/trace_enabled.go b/cmd/pico-cm5-emulator/trace_enabled.go new file mode 100644 index 0000000..4a72b13 --- /dev/null +++ b/cmd/pico-cm5-emulator/trace_enabled.go @@ -0,0 +1,5 @@ +//go:build pico_cm5_trace + +package main + +const picoCM5TraceEnabled = true diff --git a/docs/colleague-hardware-test-plan.md b/docs/colleague-hardware-test-plan.md deleted file mode 100644 index 75e1565..0000000 --- a/docs/colleague-hardware-test-plan.md +++ /dev/null @@ -1,159 +0,0 @@ -# Hardware test plan: Fabric and MCU updater - -This plan is intended for testing the Pico 2 MCU firmware against the CM5-side Devicecode stack. - -The branch deliberately separates update risk into build-time gates. Do not skip directly to the commit/reboot gate unless the earlier gates have passed on the same board and wiring. - -## Board and wiring assumptions - -- Target: Pico 2. -- TinyGo scheduler: `tasks`. -- Normal telemetry/log monitor is over USB. -- MCU Fabric link is on `uart1` as `mcu-uart0`. -- CM5-side Fabric peer expects node `mcu`, peer `bigbox-cm5`, protocol `fabric-jsonl/1`. -- `uart0` remains the original local JSON telemetry stream. - -## Common pass criteria - -For each idle gate, let the firmware run for at least 60 seconds. - -A gate passes if: - -- the expected policy line appears; -- `uart1` Fabric session opens when applicable; -- no panic or stack overflow occurs; -- memory allocation returns to a stable band rather than increasing continuously; -- temperature and power events continue to be logged. - -Stop and record the full log if any of these occur: - -- `panic:`; -- `goroutine stack overflow`; -- repeated Fabric session open/close loops; -- allocation grows monotonically across several memory samples; -- no temperature or power output after boot. - -## Gate 1: normal appliance idle - -```sh -tinygo flash -stack-size=3KB -monitor -scheduler tasks \ - -target=pico2 -tags "pico_bb_proto_1" main.go -``` - -Expected policy and Fabric mode: - -```text -[updater] policy safe-defaults:apply-disabled -[uart1] fabric session opening node=mcu peer=bigbox-cm5 link=mcu-uart0 transfer=stage-disabled -[uart1] fabric session opened node=mcu peer=bigbox-cm5 link=mcu-uart0 transfer=stage-disabled -``` - -This is the product idle gate. Fabric runs, but update transfer is deliberately refused. - -## Gate 2: transfer-capable, flash-safe appliance idle - -```sh -tinygo flash -stack-size=3KB -monitor -scheduler tasks \ - -target=pico2 -tags "pico_bb_proto_1 fabric_uart_hwtest" main.go -``` - -Expected policy and Fabric mode: - -```text -[updater] policy safe-defaults:apply-disabled -[uart1] fabric session opening node=mcu peer=bigbox-cm5 link=mcu-uart0 transfer=stage-controller:hwtest -[uart1] fabric session opened node=mcu peer=bigbox-cm5 link=mcu-uart0 transfer=stage-controller:hwtest -``` - -This build exercises the updater-owned stage-controller boundary but uses the digest/count test staging backend. It must not reboot into a staged image. `fabric_apply_enabled` is intentionally ignored for hwtest/selftest builds, so this gate remains flash-safe. - -## Gate 3: standalone Fabric protocol self-test - -```sh -tinygo flash -stack-size=4KB -monitor -scheduler tasks \ - -target=pico2 -tags "pico_bb_proto_1 fabric_uart_hwtest fabric_uart_selftest" \ - ./cmd/fabric-selftest -``` - -Expected success output: - -```text -[fabric-selftest-fw] bootstrapping bus -[fabric-selftest-fw] updater started -[fabric-selftest-fw] starting fabric transfer self-test -[fabric-selftest-fw] ok xfer=selftest-xfer-1 bytes=1024 chunk=256 digest=61d42c9c -``` - -This image starts only the bus, updater hwtest staging backend, one MCU Fabric session, and a tiny in-process CM5 peer. It is the board-level protocol regression gate. It is not the product firmware image and is expected to use a 4 KB stack. - -## Gate 4: real flash staging, commit disabled - -```sh -tinygo flash -stack-size=3KB -monitor -scheduler tasks \ - -target=pico2 -tags "pico_bb_proto_1 fabric_stage_enabled" main.go -``` - -Expected policy and Fabric mode: - -```text -[updater] policy safe-defaults:apply-disabled -[uart1] fabric session opening node=mcu peer=bigbox-cm5 link=mcu-uart0 transfer=stage-controller:flash-stage -[uart1] fabric session opened node=mcu peer=bigbox-cm5 link=mcu-uart0 transfer=stage-controller:flash-stage -``` - -This build allows the CM5 peer to stream a valid signed `.dcmcu` image into the production A/B prestage path. `commit-update` remains disabled. Use this gate to test `prepare-update`, Fabric transfer, staging validation, and `xfer_done` without reboot risk. - -Suggested CM5-side checks: - -- Device sees the MCU component and updater capability. -- `prepare-update` returns an updater target of `updater/main`. -- the transfer target is `updater/main`. -- transfer completion is observed as `xfer_done`. -- `state/self/updater` reflects staged or equivalent post-stage state. -- `commit-update` is refused because apply is disabled. - -## Gate 5: real commit and reboot - -```sh -tinygo flash -stack-size=3KB -monitor -scheduler tasks \ - -target=pico2 -tags "pico_bb_proto_1 fabric_stage_enabled fabric_apply_enabled" main.go -``` - -Expected policy and Fabric mode: - -```text -[updater] policy production-applier:commit-reboots -[uart1] fabric session opening node=mcu peer=bigbox-cm5 link=mcu-uart0 transfer=stage-controller:flash-stage -[uart1] fabric session opened node=mcu peer=bigbox-cm5 link=mcu-uart0 transfer=stage-controller:flash-stage -``` - -This is the first build that can accept `commit-update` and arm reboot. Only run it after Gate 4 has passed with the same CM5 update path and a valid image. The production applier is enabled only when both `fabric_stage_enabled` and `fabric_apply_enabled` are present, and neither `fabric_uart_hwtest` nor `fabric_uart_selftest` is present. - -Expected CM5-side result after commit: - -- the CM5 update job reaches `awaiting_return` after commit; -- the MCU reboots; -- after reconnect, Device observes the expected `image_id` and a new `boot_id`; -- the CM5 Update service reconciles the job to `succeeded`. - -## Artefacts to collect - -For each gate, collect: - -- exact TinyGo command; -- full serial monitor log from boot to at least 60 seconds, or through update completion; -- CM5-side update job id, if an update was attempted; -- final `state/device/component/mcu/software`; -- final `state/device/component/mcu/update` or equivalent update state; -- whether the MCU `boot_id` changed after commit. - -## Known current baselines - -The following have already been observed on Pico 2: - -- Gate 1 passes at 3 KB with memory returning to roughly the 114-118 KB allocation band. -- Gate 2 passes at 3 KB with similar idle behaviour. -- Gate 3 passes at 4 KB after the low-stack Fabric codec changes. -- Gate 5 idle boot passes at 3 KB, before any CM5 update traffic. - -The active production update path still needs testing with the CM5 peer and a valid signed `.dcmcu` artefact. Avoid combining `fabric_uart_hwtest` with Gate 4 or Gate 5; that tag deliberately selects the digest/count staging backend instead of real flash staging. diff --git a/docs/fabric-update-gates.md b/docs/fabric-update-gates.md deleted file mode 100644 index b8f3bf8..0000000 --- a/docs/fabric-update-gates.md +++ /dev/null @@ -1,81 +0,0 @@ -# Fabric and updater hardware gates - -This branch keeps the update path split into explicit build-time gates so the MCU firmware can be tested without accidentally enabling flash staging or reboot. - -## Appliance idle gate - -```sh -tinygo flash -stack-size=3KB -monitor -scheduler tasks \ - -target=pico2 -tags "pico_bb_proto_1" main.go -``` - -Expected policy: - -```text -transfer=stage-disabled -updater policy=safe-defaults:apply-disabled -``` - -This is the normal firmware boot/stability gate. Fabric runs on `uart1`, but `xfer_begin` is rejected with `stage_disabled`. - -## Transfer-capable, flash-safe gate - -```sh -tinygo flash -stack-size=3KB -monitor -scheduler tasks \ - -target=pico2 -tags "pico_bb_proto_1 fabric_uart_hwtest" main.go -``` - -Expected policy: - -```text -transfer=stage-controller:hwtest -updater policy=safe-defaults:apply-disabled -``` - -This uses the updater-owned stage controller, but the staging backend is a digest/count sink rather than the A/B flash writer. It is suitable for UART/Fabric transfer tests and cannot reboot into a staged image. Even if `fabric_apply_enabled` is accidentally combined with this gate, the updater remains on the safe `apply-disabled` policy. - -## Standalone Fabric protocol gate - -```sh -tinygo flash -stack-size=4KB -monitor -scheduler tasks \ - -target=pico2 -tags "pico_bb_proto_1 fabric_uart_hwtest fabric_uart_selftest" \ - ./cmd/fabric-selftest -``` - -This firmware starts only the bus, updater test staging backend, one MCU Fabric session and a tiny in-process CM5 peer. It exercises Fabric hello, prepare-update RPC, transfer chunks, digest checks and xfer_done without the full appliance Reactor. - -## Real flash staging gate - -```sh -tinygo flash -stack-size=3KB -monitor -scheduler tasks \ - -target=pico2 -tags "pico_bb_proto_1 fabric_stage_enabled" main.go -``` - -Expected policy: - -```text -transfer=stage-controller:flash-stage -updater policy=safe-defaults:apply-disabled -``` - -This allows Fabric to stream a signed `.dcmcu` image into the production A/B prestage path. Commit/reboot remains disabled: `commit-update` still returns `commit_failed`. Use this only with a valid signed image and a CM5 peer. - -## Real commit/reboot gate - -```sh -tinygo flash -stack-size=3KB -monitor -scheduler tasks \ - -target=pico2 -tags "pico_bb_proto_1 fabric_stage_enabled fabric_apply_enabled" main.go -``` - -Expected policy: - -```text -transfer=stage-controller:flash-stage -updater policy=production-applier:commit-reboots -``` - -This is the first build that can accept `commit-update` and call the production A/B reboot applier. It should only be used once the flash staging gate has passed. The `fabric_apply_enabled` tag is deliberately effective only with `fabric_stage_enabled` and not with `fabric_uart_hwtest` or `fabric_uart_selftest`; other combinations remain on the safe `apply-disabled` policy. - -## Detailed test plan - -For step-by-step hardware instructions, expected logs and artefacts to collect, see `docs/hardware-test-plan.md`. diff --git a/docs/test-plan.md b/docs/test-plan.md new file mode 100644 index 0000000..ea2e24c --- /dev/null +++ b/docs/test-plan.md @@ -0,0 +1,344 @@ +# Big Box MCU Fabric/update hardware test guide + +This guide is for testing the Pico 2 MCU firmware on real Big Box hardware, with the CM5/Lua Devicecode side acting as the Fabric sender. + +## Current baseline + +The current MCU firmware has passed the following local gates: + +* idle appliance firmware at 3 KB stack; +* two-Pico Fabric transfer testing with a Pico 1 CM5 emulator; +* 200 KiB streamed transfer at 2,048-byte chunks; +* transfer completion through `xfer_done`; +* receiver-driven retry of damaged chunks; +* 512/512 HAL serial session rings; +* yield-free `serial_raw` bounded pump; +* stable large transfer at 6 KB stack. + +Important stack baseline: + +```text +3 KB: idle/non-transfer firmware only +5 KB: known to overflow during transfer +6 KB: tested successfully for transfer +8 KB: diagnostic/probe headroom only +``` + +Use **6 KB** for real Big Box transfer, staging and commit tests. + +## Hardware assumptions + +* MCU target: Pico 2. +* TinyGo scheduler: `tasks`. +* USB monitor is connected to the Pico 2 for MCU logs. +* CM5/Lua side is the real Fabric peer and update sender. +* MCU Fabric link is on `uart1`. +* Fabric protocol is JSONL over UART. +* CM5 should see the MCU as node `mcu`. +* The updater target is `updater/main`. +* Normal appliance telemetry continues during the test. + +## General test rules + +Do not start with the commit/reboot build. + +Proceed in this order: + +1. idle boot; +2. transfer-safe hwtest; +3. real flash staging with commit disabled; +4. commit/reboot only after staging succeeds. + +For each MCU build, record: + +* exact TinyGo command; +* full MCU USB monitor log; +* CM5/Lua update job id; +* CM5-side transfer result; +* final MCU software/update state observed by Device service; +* whether the MCU rebooted; +* whether `boot_id` changed after commit. + +Stop and capture the full logs if any of these occur: + +```text +panic: +goroutine stack overflow +repeated Fabric session open/close +allocation grows monotonically over several samples +Fabric transfer does not complete or retry +unexpected reboot before commit +``` + +## Gate 1: idle appliance boot + +Purpose: confirm the normal appliance firmware boots and remains stable. + +```sh +tinygo flash -stack-size=3KB -monitor -scheduler tasks \ + -target=pico2 -tags "pico_bb_proto_1" main.go +``` + +Expected MCU log: + +```text +[updater] policy safe-defaults:apply-disabled +[uart1] fabric session opening node=mcu peer=bigbox-cm5 link=mcu-uart0 transfer=stage-disabled +[uart1] fabric session opened node=mcu peer=bigbox-cm5 link=mcu-uart0 transfer=stage-disabled +``` + +Expected behaviour: + +* no update transfer is accepted; +* `xfer_begin` should be refused or ignored as staging disabled; +* temperature/power logs continue; +* memory remains in a stable band. + +Run for at least 60 seconds. + +## Gate 2: transfer protocol hwtest + +Purpose: test real CM5/Lua Fabric transfer without writing to production flash. + +```sh +tinygo flash -stack-size=6KB -monitor -scheduler tasks \ + -target=pico2 -tags "pico_bb_proto_1 fabric_uart_hwtest" main.go +``` + +Expected MCU log: + +```text +[updater] policy safe-defaults:apply-disabled +[uart1] fabric session opening node=mcu peer=bigbox-cm5 link=mcu-uart0 transfer=stage-controller:hwtest +[uart1] fabric session opened node=mcu peer=bigbox-cm5 link=mcu-uart0 transfer=stage-controller:hwtest +``` + +Expected CM5/Lua behaviour: + +* CM5 sends Fabric `hello`; +* MCU replies `hello_ack`; +* CM5 calls `prepare-update`; +* MCU returns target `updater/main`; +* CM5 streams the update body using `xfer_begin`, `xfer_chunk`, `xfer_commit`; +* MCU returns `xfer_ready`, `xfer_need`, and finally `xfer_done`. + +This gate uses the updater-owned stage-controller path, but the backend is the safe digest/count hwtest sink. It must not reboot and must not write a real staged image. + +Receiver retries are acceptable if the transfer completes. A retry means the MCU detected a bad chunk digest, kept the same offset, and requested that offset again. It is a recovery mechanism, not a failed test. + +Pass criteria: + +```text +prepare-update succeeds +xfer_done is observed +MCU does not reboot +no panic or stack overflow +heartbeat_stop reason transfer_done appears, if OTA diagnostics are enabled +``` + +## Optional Gate 2a: quiet transfer probe + +Use this only if the CM5/Lua transfer does not complete, or if retry behaviour needs explanation. + +```sh +tinygo flash -stack-size=6KB -monitor -scheduler tasks \ + -target=pico2 \ + -tags "pico_bb_proto_1 fabric_uart_hwtest fabric_xfer_probe" \ + main.go +``` + +The probe is intentionally narrower than full Fabric trace. It records: + +```text +begin / begin_ok +chunk_digest_error +corrupt_retry +idle_retry +chunk_stale / chunk_future +commit_rx / commit_start / commit_done +``` + +Avoid `fabric_trace`, `updater_trace`, `ota_trace`, and `uartx_probe` unless debugging a specific low-level fault. They materially perturb timing and should not be used for the normal handover test. + +## Gate 3: real flash staging, commit disabled + +Purpose: test production staging of a valid signed `.dcmcu` image without allowing reboot. + +```sh +tinygo flash -stack-size=6KB -monitor -scheduler tasks \ + -target=pico2 -tags "pico_bb_proto_1 fabric_stage_enabled" main.go +``` + +Expected MCU log: + +```text +[updater] policy safe-defaults:apply-disabled +[uart1] fabric session opening node=mcu peer=bigbox-cm5 link=mcu-uart0 transfer=stage-controller:flash-stage +[uart1] fabric session opened node=mcu peer=bigbox-cm5 link=mcu-uart0 transfer=stage-controller:flash-stage +``` + +Expected CM5/Lua behaviour: + +* `prepare-update` succeeds; +* transfer target is `updater/main`; +* CM5 streams a valid signed `.dcmcu` artefact; +* MCU stages the image through the production flash staging path; +* transfer reaches `xfer_done`; +* `commit-update` is refused because apply is disabled. + +Pass criteria: + +```text +xfer_done observed +staging state is visible to CM5/Device service +commit-update is refused safely +MCU does not reboot +no panic or stack overflow +``` + +Do not include `fabric_uart_hwtest` in this gate. That tag deliberately selects the safe digest/count backend instead of production flash staging. + +## Gate 4: real commit and reboot + +Purpose: test the full production update path, including commit and reboot. + +Only run this after Gate 3 has passed on the same hardware, wiring and CM5/Lua sender. + +```sh +tinygo flash -stack-size=6KB -monitor -scheduler tasks \ + -target=pico2 -tags "pico_bb_proto_1 fabric_stage_enabled fabric_apply_enabled" main.go +``` + +Expected MCU log: + +```text +[updater] policy production-applier:commit-reboots +[uart1] fabric session opening node=mcu peer=bigbox-cm5 link=mcu-uart0 transfer=stage-controller:flash-stage +[uart1] fabric session opened node=mcu peer=bigbox-cm5 link=mcu-uart0 transfer=stage-controller:flash-stage +``` + +Expected CM5/Lua behaviour: + +* update is prepared; +* signed `.dcmcu` image is transferred and staged; +* CM5 calls `commit-update`; +* MCU accepts commit; +* MCU reboots; +* after reconnect, CM5 observes the expected new image identity and a changed `boot_id`; +* CM5 update job resolves to succeeded. + +Pass criteria: + +```text +commit-update succeeds +MCU reboots intentionally +new boot_id observed +expected image_id observed +CM5 update job reaches succeeded +``` + +## Recommended CM5/Lua sender settings + +Use the MCU-advertised `max_chunk_size` from `prepare-update`. + +Current expected value: + +```text +max_chunk_size: 2048 +``` + +The sender should treat `xfer_need.next` as authoritative. If the MCU re-requests an earlier offset, resend from that offset. Do not assume monotonically increasing acknowledgements on a UART link. + +Expected sender behaviour: + +```text +send xfer_begin +wait for xfer_ready +wait for xfer_need next=N +send xfer_chunk offset=N +repeat until next == size +send xfer_commit +wait for xfer_done +``` + +A correct sender must tolerate: + +```text +duplicate xfer_need +same-offset retry +ack timeout and resend +session restart with a new peer sid +``` + +## Notes on buffers and retries + +The current MCU transport deliberately uses bounded serial session rings rather than full-frame buffering. + +Current target constraint: + +```text +HAL serial session RX/TX: 512/512 +Fabric chunk size: 2048 +``` + +This means Fabric must behave as a streaming protocol. It must not require the HAL serial session ring to hold a complete JSONL transfer frame. + +Occasional chunk retries are acceptable. The important property is that the MCU detects corrupted chunks, does not advance the offset, and requests the same offset again. + +A retry is suspicious only if: + +```text +the same offset repeats many times +transfer never reaches xfer_done +the MCU panics or overflows stack +CM5 sees impossible future offsets +commit occurs without a completed transfer +``` + +## Known current limitations + +* 5 KB stack overflows during transfer. +* 6 KB stack is the current tested transfer baseline. +* Diagnostic probes can perturb UART timing. +* Full `fabric_trace` is too heavy for normal transfer testing. +* `uartx_probe` is for low-level attribution only, not routine gate testing. +* Gate 4 can reboot the MCU and should only be run with a known-good signed image and a planned recovery path. + +## Summary of commands + +Idle only: + +```sh +tinygo flash -stack-size=3KB -monitor -scheduler tasks \ + -target=pico2 -tags "pico_bb_proto_1" main.go +``` + +Safe transfer hwtest: + +```sh +tinygo flash -stack-size=6KB -monitor -scheduler tasks \ + -target=pico2 -tags "pico_bb_proto_1 fabric_uart_hwtest" main.go +``` + +Safe transfer hwtest with quiet probe: + +```sh +tinygo flash -stack-size=6KB -monitor -scheduler tasks \ + -target=pico2 \ + -tags "pico_bb_proto_1 fabric_uart_hwtest fabric_xfer_probe" \ + main.go +``` + +Real staging, no reboot: + +```sh +tinygo flash -stack-size=6KB -monitor -scheduler tasks \ + -target=pico2 -tags "pico_bb_proto_1 fabric_stage_enabled" main.go +``` + +Real staging, commit and reboot: + +```sh +tinygo flash -stack-size=6KB -monitor -scheduler tasks \ + -target=pico2 -tags "pico_bb_proto_1 fabric_stage_enabled fabric_apply_enabled" main.go +``` diff --git a/docs/uartx-probe.md b/docs/uartx-probe.md new file mode 100644 index 0000000..6abc756 --- /dev/null +++ b/docs/uartx-probe.md @@ -0,0 +1,54 @@ +# UARTX probe build + +This tree vendors a local copy of `github.com/jangala-dev/tinygo-uartx` under +`third_party/tinygo-uartx` so that the firmware can expose loss-attribution +counters while we debug Fabric transfers over real UART. + +Normal builds use the same UART API. To enable the extra serial diagnostics, +add the `uartx_probe` build tag to the Pico 2 build: + +```sh +tinygo flash -stack-size=8KB -monitor -scheduler tasks \ + -target=pico2 \ + -tags "pico_bb_proto_1 fabric_uart_hwtest fabric_xfer_probe uartx_probe" \ + main.go +``` + +The probe prints compact lines from the HAL `serial_raw` session worker: + +```text +[uartx-probe] uart1 reason periodic rx_hw ... rx_drop ... rx_oe ... rx_fe ... sess_rx_avail ... +``` + +The most useful fields are: + +- `rx_hw`: bytes read from the PL011 data register by the UARTX ISR. +- `rx_enq`: bytes successfully enqueued into UARTX's ISR RX ring. +- `rx_read`: bytes drained from UARTX by the HAL serial session worker. +- `rx_drop`: bytes dropped because the UARTX ISR RX ring was full. +- `rx_oe`, `rx_fe`, `rx_pe`, `rx_be`: PL011 overrun, framing, parity and break errors. +- `rx_max`: maximum observed UARTX ISR RX ring occupancy. +- `sess_rx_avail` / `sess_rx_space`: bytes in the HAL session shmring from UART to Fabric. +- `sess_tx_avail` / `sess_tx_space`: bytes in the HAL session shmring from Fabric to UART. + +When Fabric logs `chunk_digest_error` with a shortened `encoded_len`, compare the +nearest `[uartx-probe]` lines. If `rx_drop` or `rx_oe` increments, the loss is at +or below the UARTX ISR ring. If those counters remain flat while the HAL session +ring is full, the loss is likely at the session boundary. If all counters remain +flat, look higher in the line assembly / Fabric parser path. + + +## Current bounded-session test + +The Pico 2 board setups now use symmetrical 512-byte HAL serial session rings +for both raw UART devices. The Pico 1 CM5 emulator opens its UART session with +the same 512/512 constraint. This is deliberately smaller than a Fabric transfer +line; Fabric must rely on streaming, flow control and retry rather than requiring +the HAL session ring to hold an entire JSONL frame. + +The old 32-byte RX rings came from the earlier raw-JSON telemetry shape and are +now too small for bidirectional Fabric traffic. The 512-byte setting is intended +as a bounded engineering test, not as a hidden full-frame buffer. + +The local UARTX copy should be treated as an instrumentation branch. Once the +cause is confirmed, port only the relevant counter or behavioural fix upstream. diff --git a/go.mod b/go.mod index a2a8762..4452920 100644 --- a/go.mod +++ b/go.mod @@ -12,3 +12,5 @@ require ( require github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 // indirect replace pico2-a-b => ../pico2-a-b + +replace github.com/jangala-dev/tinygo-uartx => ./third_party/tinygo-uartx diff --git a/go.sum b/go.sum index a00618c..796115a 100644 --- a/go.sum +++ b/go.sum @@ -1,7 +1,5 @@ github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4= github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ= -github.com/jangala-dev/tinygo-uartx v0.0.0-20251028085354-58b6258234b3 h1:b6mCDQEeeICoGpsbKyh/kfIRnr2DMK/wACLLi0t8uoU= -github.com/jangala-dev/tinygo-uartx v0.0.0-20251028085354-58b6258234b3/go.mod h1:e3HxjGzBZBIsn/oYvWr707ug3IbkglEyivyYVxHRph4= golang.org/x/exp v0.0.0-20251002181428-27f1f14c8bb9 h1:TQwNpfvNkxAVlItJf6Cr5JTsVZoC/Sj7K3OZv2Pc14A= golang.org/x/exp v0.0.0-20251002181428-27f1f14c8bb9/go.mod h1:TwQYMMnGpvZyc+JpB/UAuTNIsVJifOlSkrZkhcvpVUk= tinygo.org/x/drivers v0.33.0 h1:5r8Ab0IxjWQi7LzYLNWpya6U4nedo9ZtxeMaAzrJTG8= diff --git a/original.zip b/original.zip deleted file mode 100644 index 7df626146aae7ba81633915a746aca2b3f2521c4..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 210046 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

yTbu*%k9(tQD>i4t9X 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 exportMaxPerTick { - return true + if m := latestSubscriptionMessage(sub); m != nil { + s.queueCriticalExport(i, m) } - m := latestSubscriptionMessage(sub) + } + for i, m := range s.criticalExportPendingMsgs { if m == nil { if i < len(s.criticalExportReplayPending) && s.criticalExportReplayPending[i] { return true } continue } + if *total >= exportMaxPerTick { + return true + } sent, ok := s.sendExportMessage(m) if !ok { return false } - if sent && i < len(s.criticalExportReplayPending) && s.criticalExportReplayPending[i] { - s.criticalExportReplayPending[i] = false - } if sent { (*total)++ + if i < len(s.criticalExportReplayPending) && s.criticalExportReplayPending[i] { + s.criticalExportReplayPending[i] = false + } + s.criticalExportPendingMsgs[i] = nil } } return true @@ -1395,9 +1421,21 @@ func (s *session) queueExport(m *bus.Message) { if m == nil { return } - // Keep the queue bounded. The bus subscription itself coalesces retained - // changes, but once a watcher has handed the event to the session we still - // avoid unbounded growth during handshake holdoff. + // Retained exports are a cache: when several retained facts for the same + // topic arrive before the UART writer has capacity, only the newest value is + // useful on the wire. Coalescing here keeps the output path event-driven and + // avoids a semantic "pause exports during transfer" policy. + if m.Retained { + for i, pending := range s.exportPendingMsgs { + if pending != nil && pending.Retained && topicEquals(pending.Topic, m.Topic) { + s.exportPendingMsgs[i] = m + return + } + } + } + // Non-retained events are sparse, but keep the FIFO bounded. If the link is + // congested, old non-critical observations are less valuable than keeping the + // reactor and control frames moving. const maxPendingExports = 32 if len(s.exportPendingMsgs) >= maxPendingExports { copy(s.exportPendingMsgs, s.exportPendingMsgs[1:]) @@ -1407,38 +1445,36 @@ func (s *session) queueExport(m *bus.Message) { s.exportPendingMsgs = append(s.exportPendingMsgs, m) } +func (s *session) hasCriticalExportBacklog() bool { + for i, m := range s.criticalExportPendingMsgs { + if m != nil { + return true + } + if i < len(s.criticalExportReplayPending) && s.criticalExportReplayPending[i] { + return true + } + } + return false +} + +func (s *session) hasExportBacklog() bool { + return s.hasCriticalExportBacklog() || len(s.exportPendingMsgs) > 0 +} + func (s *session) handleCriticalExportEvent(idx int, m *bus.Message) { if idx < 0 || idx >= len(s.criticalExportReplayPending) { return } - if !s.exportCanSend(time.Now()) { - s.queueCriticalExport(idx, m) - return - } - sent, ok := s.sendExportMessage(m) - if !ok { - s.queueCriticalExport(idx, m) - return - } - if sent && s.criticalExportReplayPending[idx] { - s.criticalExportReplayPending[idx] = false - } - s.criticalExportPendingMsgs[idx] = nil - s.drainQueuedExports() + s.queueCriticalExport(idx, m) + s.scheduleExportDrain(time.Now()) } func (s *session) handleExportEvent(m *bus.Message) { - if !s.exportCanSend(time.Now()) || !s.criticalExportReplayDrained() { - s.queueExport(m) - return - } - if len(s.criticalExportSubs) > 0 && isCriticalExportTopic(m.Topic) { + if m == nil || (len(s.criticalExportSubs) > 0 && isCriticalExportTopic(m.Topic)) { return } - _, ok := s.sendExportMessage(m) - if !ok { - s.queueExport(m) - } + s.queueExport(m) + s.scheduleExportDrain(time.Now()) } func (s *session) drainQueuedExports() { @@ -1446,34 +1482,31 @@ func (s *session) drainQueuedExports() { if !s.exportCanSend(now) { return } - for i, m := range s.criticalExportPendingMsgs { - if m == nil || (i < len(s.criticalExportReplayPending) && !s.criticalExportReplayPending[i]) { - continue - } - sent, ok := s.sendExportMessage(m) - if !ok { - return - } - if sent && i < len(s.criticalExportReplayPending) { - s.criticalExportReplayPending[i] = false - s.criticalExportPendingMsgs[i] = nil - } + total := 0 + if !s.drainCriticalExports(&total) { + return } if !s.criticalExportReplayDrained() { s.scheduleExportDrain(now) return } - for len(s.exportPendingMsgs) > 0 { + for total < exportMaxPerTick && len(s.exportPendingMsgs) > 0 { m := s.exportPendingMsgs[0] s.exportPendingMsgs = s.exportPendingMsgs[1:] if m == nil || (len(s.criticalExportSubs) > 0 && isCriticalExportTopic(m.Topic)) { continue } - _, ok := s.sendExportMessage(m) + sent, ok := s.sendExportMessage(m) if !ok { s.exportPendingMsgs = append([]*bus.Message{m}, s.exportPendingMsgs...) return } + if sent { + total++ + } + } + if s.hasExportBacklog() { + s.scheduleExportDrain(now) } } @@ -1489,29 +1522,15 @@ func (s *session) scheduleExportDrain(now time.Time) { func (s *session) drainExports() { now := time.Now() s.exportDrainAt = time.Time{} - if s.link != linkUp { - return - } - if !s.exportsEnabled { - return - } - if !s.exportReadyAt.IsZero() && now.Before(s.exportReadyAt) { - return - } - total := 0 - if !s.drainCriticalExports(&total) { - return - } - if !s.criticalExportReplayDrained() { - s.scheduleExportDrain(now) + if !s.exportCanSend(now) { return } + // Collect all immediately-available export notifications into the session's + // coalesced retained queue. Sending is handled by drainQueuedExports below, + // with a small per-tick budget. This keeps retained replay fair without + // making transfer state a special case. for _, sub := range s.exportSubs { for { - if total >= exportMaxPerTick { - s.scheduleExportDrain(now) - return - } select { case m, ok := <-sub.Channel(): if !ok || m == nil { @@ -1520,19 +1539,14 @@ func (s *session) drainExports() { if len(s.criticalExportSubs) > 0 && isCriticalExportTopic(m.Topic) { continue } - sent, ok := s.sendExportMessage(m) - if !ok { - return - } - if sent { - total++ - } + s.queueExport(m) default: goto nextSub } } nextSub: } + s.drainQueuedExports() } func (s *session) findInboundCall(id string) (*inboundCall, int) { diff --git a/services/fabric/transfer.go b/services/fabric/transfer.go index ea422cd..e8b09bb 100644 --- a/services/fabric/transfer.go +++ b/services/fabric/transfer.go @@ -19,6 +19,10 @@ const transferIdleRetryLimit = 3 const transferCorruptRetryLimit = 3 const completedTransferCacheLimit = 4 +func fabricXferDiagEnabled(event string) bool { + return otadiag.Enabled("[fabric-xfer]", event) +} + // transferMeta captures xfer_begin contents. The transfer target is explicit // on the wire; firmware update uses target="updater/main". meta remains opaque // and informational to Fabric. @@ -390,19 +394,47 @@ func (s *session) decodeChunkData(encoded string) ([]byte, string) { } func (s *session) sendTransferReady(id string) bool { - return s.sendControl(marshalXferReady(id)) + if fabricTraceEnabled { + println("[fabric-xfer]", "sid", s.localSID, "tx_ready_start", "xfer", id) + } + ok := s.sendControl(marshalXferReady(id)) + if fabricTraceEnabled { + println("[fabric-xfer]", "sid", s.localSID, "tx_ready_done", "xfer", id, "ok", ok) + } + return ok } func (s *session) sendTransferNeed(id string, next uint32) bool { - return s.sendControl(marshalXferNeed(id, next)) + if fabricTraceEnabled { + println("[fabric-xfer]", "sid", s.localSID, "tx_need_start", "xfer", id, "next", next) + } + ok := s.sendControl(marshalXferNeed(id, next)) + if fabricTraceEnabled { + println("[fabric-xfer]", "sid", s.localSID, "tx_need_done", "xfer", id, "next", next, "ok", ok) + } + return ok } func (s *session) sendTransferDone(id string) bool { - return s.sendControl(marshalXferDone(id)) + if fabricTraceEnabled { + println("[fabric-xfer]", "sid", s.localSID, "tx_done_start", "xfer", id) + } + ok := s.sendControl(marshalXferDone(id)) + if fabricTraceEnabled { + println("[fabric-xfer]", "sid", s.localSID, "tx_done_done", "xfer", id, "ok", ok) + } + return ok } func (s *session) sendTransferAbort(id, reason string) bool { - return s.sendControl(marshalXferAbort(id, reason)) + if fabricTraceEnabled { + println("[fabric-xfer]", "sid", s.localSID, "tx_abort_start", "xfer", id, "reason", reason) + } + ok := s.sendControl(marshalXferAbort(id, reason)) + if fabricTraceEnabled { + println("[fabric-xfer]", "sid", s.localSID, "tx_abort_done", "xfer", id, "reason", reason, "ok", ok) + } + return ok } func (s *session) clearTransfer() *incomingTransfer { @@ -465,6 +497,9 @@ func (s *session) checkTransferTimeout(now time.Time) { cur.idleRetries++ cur.deadline = now.Add(s.cfg.PhaseTimeout) s.logKV("transfer idle retry", "offset", u32s(cur.bytesWritten)) + if xferProbeEnabled { + xferProbe("idle_retry", "id", cur.meta.ID, "next", cur.bytesWritten, "retry", int(cur.idleRetries)) + } s.sendTransferNeed(cur.meta.ID, cur.bytesWritten) return } @@ -493,6 +528,9 @@ func (s *session) retryCorruptTransferFrame(reason string) bool { } s.counters.TransferOffsetRetries++ cur.corruptRetriesAtOffset++ + if xferProbeEnabled { + xferProbe("corrupt_retry", "id", cur.meta.ID, "next", cur.bytesWritten, "retry", int(cur.corruptRetriesAtOffset), "reason", reason) + } needOK := s.sendTransferNeed(cur.meta.ID, cur.bytesWritten) otadiag.Event( "[fabric-xfer]", "need_tx", cur.meta.ID, @@ -564,14 +602,19 @@ func validateTransferBegin(msg *protoXferBegin) (transferMeta, string) { func (s *session) onTransferBegin(msg *protoXferBegin) { otadiag.SetActiveXfer(msg.XferID) - otadiag.Event( - "[fabric-xfer]", "begin_rx", msg.XferID, - otadiag.KV("target", msg.Target), - otadiag.KV("size", msg.Size), - otadiag.KV("digest_alg", msg.DigestAlg), - otadiag.KV("digest", msg.Digest), - otadiag.KV("meta_len", len(msg.Meta)), - ) + if fabricTraceEnabled { + println("[fabric-xfer]", "sid", s.localSID, "begin_enter", "xfer", msg.XferID, "target", msg.Target, "size", msg.Size, "digest", msg.Digest) + } + if fabricXferDiagEnabled("begin_rx") { + otadiag.Event( + "[fabric-xfer]", "begin_rx", msg.XferID, + otadiag.KV("target", msg.Target), + otadiag.KV("size", msg.Size), + otadiag.KV("digest_alg", msg.DigestAlg), + otadiag.KV("digest", msg.Digest), + otadiag.KV("meta_len", len(msg.Meta)), + ) + } meta, errStr := validateTransferBegin(msg) if errStr != "" { if msg.XferID != "" { @@ -592,10 +635,15 @@ func (s *session) onTransferBegin(msg *protoXferBegin) { s.logKV("xfer_begin dropped", "err", errStr) return } - otadiag.Event( - "[fabric-xfer]", "begin_validate_ok", meta.ID, - otadiag.KV("target", meta.Target), - ) + if fabricXferDiagEnabled("begin_validate_ok") { + otadiag.Event( + "[fabric-xfer]", "begin_validate_ok", meta.ID, + otadiag.KV("target", meta.Target), + ) + } + if xferProbeEnabled { + xferProbe("begin", "id", meta.ID, "size", meta.Size, "target", meta.Target, "digest", meta.Digest) + } s.markRx() now := time.Now() if s.incomingTransfer != nil { @@ -603,10 +651,14 @@ func (s *session) onTransferBegin(msg *protoXferBegin) { if sameTransferTuple(cur.meta, meta) { s.logKV("xfer_begin duplicate", "id", meta.ID) readyOK := s.sendTransferReady(meta.ID) - otadiag.Event("[fabric-xfer]", "ready_tx", meta.ID, otadiag.KV("ok", readyOK), otadiag.KV("duplicate", true)) + if fabricXferDiagEnabled("ready_tx") { + otadiag.Event("[fabric-xfer]", "ready_tx", meta.ID, otadiag.KV("ok", readyOK), otadiag.KV("duplicate", true)) + } if readyOK { needOK := s.sendTransferNeed(meta.ID, cur.bytesWritten) - otadiag.Event("[fabric-xfer]", "need_tx", meta.ID, otadiag.KV("next", cur.bytesWritten), otadiag.KV("ok", needOK), otadiag.KV("duplicate", true)) + if fabricXferDiagEnabled("need_tx") { + otadiag.Event("[fabric-xfer]", "need_tx", meta.ID, otadiag.KV("next", cur.bytesWritten), otadiag.KV("ok", needOK), otadiag.KV("duplicate", true)) + } } cur.deadline = now.Add(s.cfg.PhaseTimeout) return @@ -640,7 +692,9 @@ func (s *session) onTransferBegin(msg *protoXferBegin) { if done, ok := s.completedTransferFor(meta.ID); ok { if sameTransferTuple(done, meta) { doneOK := s.sendTransferDone(meta.ID) - otadiag.Event("[fabric-xfer]", "begin_duplicate_done", meta.ID, otadiag.KV("done_tx", doneOK)) + if fabricXferDiagEnabled("begin_duplicate_done") { + otadiag.Event("[fabric-xfer]", "begin_duplicate_done", meta.ID, otadiag.KV("done_tx", doneOK)) + } return } abortOK := s.sendTransferAbort(meta.ID, "conflicting_transfer") @@ -654,14 +708,22 @@ func (s *session) onTransferBegin(msg *protoXferBegin) { return beginUpdaterTransfer(s.stageController, meta) } } + if fabricTraceEnabled { + println("[fabric-xfer]", "sid", s.localSID, "begin_sink_start", "xfer", meta.ID, "target", meta.Target, "size", meta.Size) + } beginStart := time.Now() - otadiag.Event( - "[fabric-xfer]", "begin_transfer_start", meta.ID, - otadiag.KV("target", meta.Target), - otadiag.KV("size", meta.Size), - ) + if fabricXferDiagEnabled("begin_transfer_start") { + otadiag.Event( + "[fabric-xfer]", "begin_transfer_start", meta.ID, + otadiag.KV("target", meta.Target), + otadiag.KV("size", meta.Size), + ) + } sink, err := beginFn(meta) if err != nil { + if fabricTraceEnabled { + println("[fabric-xfer]", "sid", s.localSID, "begin_sink_error", "xfer", meta.ID, "err", err.Error()) + } durMS := int(time.Since(beginStart) / time.Millisecond) abortOK := s.sendTransferAbort(meta.ID, err.Error()) otadiag.Event( @@ -673,10 +735,15 @@ func (s *session) onTransferBegin(msg *protoXferBegin) { otadiag.StopUpdateWindow("begin_transfer_error") return } - otadiag.Event( - "[fabric-xfer]", "begin_transfer_done", meta.ID, - otadiag.KV("dur_ms", int(time.Since(beginStart)/time.Millisecond)), - ) + if fabricXferDiagEnabled("begin_transfer_done") { + otadiag.Event( + "[fabric-xfer]", "begin_transfer_done", meta.ID, + otadiag.KV("dur_ms", int(time.Since(beginStart)/time.Millisecond)), + ) + } + if fabricTraceEnabled { + println("[fabric-xfer]", "sid", s.localSID, "begin_sink_ok", "xfer", meta.ID) + } s.counters.TransferBegins++ s.incomingTransfer = &incomingTransfer{ meta: meta, @@ -684,17 +751,32 @@ func (s *session) onTransferBegin(msg *protoXferBegin) { hasher: xxhash.New(0), deadline: now.Add(s.cfg.PhaseTimeout), } + if xferProbeEnabled { + xferProbe("begin_ok", "id", meta.ID, "next", uint32(0)) + } readyOK := s.sendTransferReady(meta.ID) - otadiag.Event("[fabric-xfer]", "ready_tx", meta.ID, otadiag.KV("ok", readyOK)) + if fabricXferDiagEnabled("ready_tx") { + otadiag.Event("[fabric-xfer]", "ready_tx", meta.ID, otadiag.KV("ok", readyOK)) + } if readyOK { needOK := s.sendTransferNeed(meta.ID, 0) - otadiag.Event("[fabric-xfer]", "need_tx", meta.ID, otadiag.KV("next", 0), otadiag.KV("ok", needOK)) + if fabricXferDiagEnabled("need_tx") { + otadiag.Event("[fabric-xfer]", "need_tx", meta.ID, otadiag.KV("next", 0), otadiag.KV("ok", needOK)) + } } else { - otadiag.Event("[fabric-xfer]", "need_tx", meta.ID, otadiag.KV("next", 0), otadiag.KV("ok", false), otadiag.KV("skipped", "ready_failed")) + if fabricXferDiagEnabled("need_tx") { + otadiag.Event("[fabric-xfer]", "need_tx", meta.ID, otadiag.KV("next", 0), otadiag.KV("ok", false), otadiag.KV("skipped", "ready_failed")) + } } } func (s *session) startPendingChunkWrite(cur *incomingTransfer, offset uint32, raw []byte) { + if xferProbeEnabled { + xferProbe("write_start", "id", cur.meta.ID, "offset", offset, "len", len(raw)) + } + if fabricTraceEnabled { + println("[fabric-xfer]", "sid", s.localSID, "chunk_worker_start", "xfer", cur.meta.ID, "offset", offset, "len", len(raw)) + } ch := make(chan transferChunkResult, 1) data := raw started := time.Now() @@ -711,12 +793,26 @@ func (s *session) startPendingChunkWrite(cur *incomingTransfer, offset uint32, r } func (s *session) finishChunkWrite(now time.Time, res transferChunkResult) { + if fabricTraceEnabled { + errText := "" + if res.err != nil { + errText = res.err.Error() + } + println("[fabric-xfer]", "sid", s.localSID, "chunk_worker_done", "err", errText) + } cur := s.incomingTransfer if cur == nil || cur.pendingChunk == nil { return } pending := cur.pendingChunk cur.pendingChunk = nil + if xferProbeEnabled { + errText := "" + if res.err != nil { + errText = res.err.Error() + } + xferProbe("write_done", "id", pending.xferID, "offset", pending.offset, "dur_ms", int(time.Since(pending.started)/time.Millisecond), "err", errText) + } if res.err != nil { reason := res.err.Error() otadiag.Event( @@ -739,22 +835,32 @@ func (s *session) finishChunkWrite(now time.Time, res transferChunkResult) { cur.corruptRetryOffset = cur.bytesWritten cur.corruptRetriesAtOffset = 0 cur.deadline = now.Add(s.cfg.PhaseTimeout) - otadiag.Event( - "[fabric-xfer]", "sink_write_done", pending.xferID, - otadiag.KV("dur_ms", int(time.Since(pending.started)/time.Millisecond)), - otadiag.KV("next", u32s(cur.bytesWritten)), - ) + if fabricXferDiagEnabled("sink_write_done") { + otadiag.Event( + "[fabric-xfer]", "sink_write_done", pending.xferID, + otadiag.KV("dur_ms", int(time.Since(pending.started)/time.Millisecond)), + otadiag.KV("next", u32s(cur.bytesWritten)), + ) + } pending.data = nil + if xferProbeEnabled { + xferProbe("need_after_write", "id", cur.meta.ID, "next", cur.bytesWritten) + } needOK := s.sendTransferNeed(cur.meta.ID, cur.bytesWritten) - otadiag.Event( - "[fabric-xfer]", "need_tx", cur.meta.ID, - otadiag.KV("next", cur.bytesWritten), - otadiag.KV("ok", needOK), - otadiag.KV("accepted", true), - ) + if fabricXferDiagEnabled("need_tx") { + otadiag.Event( + "[fabric-xfer]", "need_tx", cur.meta.ID, + otadiag.KV("next", cur.bytesWritten), + otadiag.KV("ok", needOK), + otadiag.KV("accepted", true), + ) + } } func (s *session) startPendingTransferCommit(cur *incomingTransfer) { + if xferProbeEnabled { + xferProbe("commit_start", "id", cur.meta.ID, "bytes", cur.bytesWritten, "chunks", cur.chunksSeen) + } ch := make(chan transferCommitResult, 1) started := time.Now() cur.pendingCommit = &pendingTransferCommit{ @@ -768,12 +874,26 @@ func (s *session) startPendingTransferCommit(cur *incomingTransfer) { } func (s *session) finishTransferCommit(now time.Time, res transferCommitResult) { + if fabricTraceEnabled { + errText := "" + if res.err != nil { + errText = res.err.Error() + } + println("[fabric-xfer]", "sid", s.localSID, "commit_worker_done", "bytes", res.info.BytesWritten, "generation", res.info.Generation, "err", errText) + } cur := s.incomingTransfer if cur == nil || cur.pendingCommit == nil { return } pending := cur.pendingCommit cur.pendingCommit = nil + if xferProbeEnabled { + errText := "" + if res.err != nil { + errText = res.err.Error() + } + xferProbe("commit_done", "id", pending.xferID, "dur_ms", int(time.Since(pending.started)/time.Millisecond), "bytes", res.info.BytesWritten, "generation", res.info.Generation, "err", errText) + } if res.err != nil { reason := res.err.Error() s.logKV("transfer commit failed", "err", reason) @@ -784,10 +904,12 @@ func (s *session) finishTransferCommit(now time.Time, res transferCommitResult) } meta := cur.meta s.clearTransfer() - otadiag.Event( - "[fabric-xfer]", "transfer_commit_done", pending.xferID, - otadiag.KV("dur_ms", int(time.Since(pending.started)/time.Millisecond)), - ) + if fabricXferDiagEnabled("transfer_commit_done") { + otadiag.Event( + "[fabric-xfer]", "transfer_commit_done", pending.xferID, + otadiag.KV("dur_ms", int(time.Since(pending.started)/time.Millisecond)), + ) + } if reason := s.startTransferTargetCall(meta, pending.xferID, res.info); reason != "" { res.info.cancelStage(reason) abortOK := s.sendTransferAbort(pending.xferID, reason) @@ -803,73 +925,105 @@ func (s *session) onTransferChunk(msg *protoXferChunk) { return } id := cur.meta.ID + if xferProbeEnabled { + xferProbe("chunk_rx", "id", id, "offset", msg.Offset, "expected", cur.bytesWritten, "encoded_len", len(msg.Data)) + } if cur.pendingChunk != nil { + if xferProbeEnabled { + xferProbe("chunk_drop_write_pending", "id", id, "offset", msg.Offset, "expected", cur.bytesWritten, "pending", cur.pendingChunk.offset) + } s.markRx() - otadiag.Event( - "[fabric-xfer]", "chunk_while_write_pending", id, - otadiag.KV("offset", u32s(msg.Offset)), - otadiag.KV("pending_offset", u32s(cur.pendingChunk.offset)), - otadiag.KV("expected", u32s(cur.bytesWritten)), - ) + if fabricXferDiagEnabled("chunk_while_write_pending") { + otadiag.Event( + "[fabric-xfer]", "chunk_while_write_pending", id, + otadiag.KV("offset", u32s(msg.Offset)), + otadiag.KV("pending_offset", u32s(cur.pendingChunk.offset)), + otadiag.KV("expected", u32s(cur.bytesWritten)), + ) + } return } if cur.pendingCommit != nil { + if xferProbeEnabled { + xferProbe("chunk_drop_commit_pending", "id", id, "offset", msg.Offset, "expected", cur.bytesWritten) + } s.markRx() + if fabricXferDiagEnabled("chunk_while_commit_pending") { + otadiag.Event( + "[fabric-xfer]", "chunk_while_commit_pending", id, + otadiag.KV("offset", u32s(msg.Offset)), + otadiag.KV("expected", u32s(cur.bytesWritten)), + ) + } + return + } + if fabricXferDiagEnabled("chunk_rx") { otadiag.Event( - "[fabric-xfer]", "chunk_while_commit_pending", id, + "[fabric-xfer]", "chunk_rx", id, otadiag.KV("offset", u32s(msg.Offset)), otadiag.KV("expected", u32s(cur.bytesWritten)), + otadiag.KV("encoded_len", strconvx.Itoa(len(msg.Data))), ) - return } - otadiag.Event( - "[fabric-xfer]", "chunk_rx", id, - otadiag.KV("offset", u32s(msg.Offset)), - otadiag.KV("expected", u32s(cur.bytesWritten)), - otadiag.KV("encoded_len", strconvx.Itoa(len(msg.Data))), - ) if msg.Offset < cur.bytesWritten { + if xferProbeEnabled { + xferProbe("chunk_stale", "id", id, "offset", msg.Offset, "expected", cur.bytesWritten) + } s.markRx() needOK := s.sendTransferNeed(id, cur.bytesWritten) - otadiag.Event( - "[fabric-xfer]", "chunk_stale_offset", id, - otadiag.KV("offset", u32s(msg.Offset)), - otadiag.KV("expected", u32s(cur.bytesWritten)), - otadiag.KV("need_tx", needOK), - ) + if fabricXferDiagEnabled("chunk_stale_offset") { + otadiag.Event( + "[fabric-xfer]", "chunk_stale_offset", id, + otadiag.KV("offset", u32s(msg.Offset)), + otadiag.KV("expected", u32s(cur.bytesWritten)), + otadiag.KV("need_tx", needOK), + ) + } return } if msg.Offset > cur.bytesWritten { + if xferProbeEnabled { + xferProbe("chunk_future", "id", id, "offset", msg.Offset, "expected", cur.bytesWritten) + } s.markRx() needOK := s.sendTransferNeed(id, cur.bytesWritten) - otadiag.Event( - "[fabric-xfer]", "chunk_future_offset", id, - otadiag.KV("offset", u32s(msg.Offset)), - otadiag.KV("expected", u32s(cur.bytesWritten)), - otadiag.KV("need_tx", needOK), - ) + if fabricXferDiagEnabled("chunk_future_offset") { + otadiag.Event( + "[fabric-xfer]", "chunk_future_offset", id, + otadiag.KV("offset", u32s(msg.Offset)), + otadiag.KV("expected", u32s(cur.bytesWritten)), + otadiag.KV("need_tx", needOK), + ) + } return } decodeStart := time.Now() raw, errStr := s.decodeChunkData(msg.Data) if errStr != "" { + if xferProbeEnabled { + xferProbe("chunk_decode_error", "id", id, "offset", msg.Offset, "reason", errStr, "encoded_len", len(msg.Data)) + } s.counters.TransferDecodeErrors++ + if fabricXferDiagEnabled("chunk_decode_done") { + otadiag.Event( + "[fabric-xfer]", "chunk_decode_done", id, + otadiag.KV("ok", false), + otadiag.KV("reason", errStr), + otadiag.KV("dur_ms", int(time.Since(decodeStart)/time.Millisecond)), + ) + } + s.logKV("xfer_chunk decode retry", "err", errStr) + s.retryCorruptTransferFrame(errStr) + return + } + if fabricXferDiagEnabled("chunk_decode_done") { otadiag.Event( "[fabric-xfer]", "chunk_decode_done", id, - otadiag.KV("ok", false), - otadiag.KV("reason", errStr), + otadiag.KV("ok", true), + otadiag.KV("raw_len", strconvx.Itoa(len(raw))), otadiag.KV("dur_ms", int(time.Since(decodeStart)/time.Millisecond)), ) - s.logKV("xfer_chunk decode retry", "err", errStr) - s.retryCorruptTransferFrame(errStr) - return } - otadiag.Event( - "[fabric-xfer]", "chunk_decode_done", id, - otadiag.KV("ok", true), - otadiag.KV("raw_len", strconvx.Itoa(len(raw))), - otadiag.KV("dur_ms", int(time.Since(decodeStart)/time.Millisecond)), - ) if len(raw) == 0 { s.abortTransfer("empty_chunk") abortOK := s.sendTransferAbort(id, "empty_chunk") @@ -878,12 +1032,14 @@ func (s *session) onTransferChunk(msg *protoXferChunk) { } if cur.bytesWritten+uint32(len(raw)) > cur.meta.Size { reason := "size_too_large" - otadiag.Event( - "[fabric-xfer]", "chunk_size_overflow", id, - otadiag.KV("offset", u32s(msg.Offset)), - otadiag.KV("raw_len", strconvx.Itoa(len(raw))), - otadiag.KV("size", u32s(cur.meta.Size)), - ) + if fabricXferDiagEnabled("chunk_size_overflow") { + otadiag.Event( + "[fabric-xfer]", "chunk_size_overflow", id, + otadiag.KV("offset", u32s(msg.Offset)), + otadiag.KV("raw_len", strconvx.Itoa(len(raw))), + otadiag.KV("size", u32s(cur.meta.Size)), + ) + } s.abortTransfer(reason) abortOK := s.sendTransferAbort(id, reason) otadiag.Event("[fabric-xfer]", "abort_tx", id, otadiag.KV("reason", reason), otadiag.KV("ok", abortOK)) @@ -897,50 +1053,66 @@ func (s *session) onTransferChunk(msg *protoXferChunk) { digestStart := time.Now() want, ok := canonicalXXHash32Hex(msg.ChunkDigest) if !ok { + if xferProbeEnabled { + xferProbe("chunk_digest_error", "id", id, "offset", msg.Offset, "reason", "bad_message", "digest_len", len(msg.ChunkDigest), "data_len", len(msg.Data)) + } s.counters.TransferDigestErrors++ - otadiag.Event( - "[fabric-xfer]", "chunk_digest_done", id, - otadiag.KV("ok", false), - otadiag.KV("reason", "bad_message"), - otadiag.KV("offset", u32s(msg.Offset)), - otadiag.KV("digest_len", strconvx.Itoa(len(msg.ChunkDigest))), - otadiag.KV("data_len", strconvx.Itoa(len(msg.Data))), - otadiag.KV("dur_ms", int(time.Since(digestStart)/time.Millisecond)), - ) + if fabricXferDiagEnabled("chunk_digest_done") { + otadiag.Event( + "[fabric-xfer]", "chunk_digest_done", id, + otadiag.KV("ok", false), + otadiag.KV("reason", "bad_message"), + otadiag.KV("offset", u32s(msg.Offset)), + otadiag.KV("digest_len", strconvx.Itoa(len(msg.ChunkDigest))), + otadiag.KV("data_len", strconvx.Itoa(len(msg.Data))), + otadiag.KV("dur_ms", int(time.Since(digestStart)/time.Millisecond)), + ) + } s.retryCorruptTransferFrame("bad_message") return } got := xxhashHex(xxhash.Sum32(raw, 0)) if got != want { + if xferProbeEnabled { + xferProbe("chunk_digest_error", "id", id, "offset", msg.Offset, "reason", "mismatch", "got", got, "want", want) + } s.counters.TransferDigestErrors++ + if fabricXferDiagEnabled("chunk_digest_done") { + otadiag.Event( + "[fabric-xfer]", "chunk_digest_done", id, + otadiag.KV("ok", false), + otadiag.KV("reason", "chunk_digest_mismatch"), + otadiag.KV("offset", u32s(msg.Offset)), + otadiag.KV("dur_ms", int(time.Since(digestStart)/time.Millisecond)), + ) + } + s.retryCorruptTransferFrame("chunk_digest_mismatch") + return + } + if fabricXferDiagEnabled("chunk_digest_done") { otadiag.Event( "[fabric-xfer]", "chunk_digest_done", id, - otadiag.KV("ok", false), - otadiag.KV("reason", "chunk_digest_mismatch"), - otadiag.KV("offset", u32s(msg.Offset)), + otadiag.KV("ok", true), otadiag.KV("dur_ms", int(time.Since(digestStart)/time.Millisecond)), ) - s.retryCorruptTransferFrame("chunk_digest_mismatch") - return } - otadiag.Event( - "[fabric-xfer]", "chunk_digest_done", id, - otadiag.KV("ok", true), - otadiag.KV("dur_ms", int(time.Since(digestStart)/time.Millisecond)), - ) s.markRx() writeStart := time.Now() - otadiag.Event( - "[fabric-xfer]", "sink_write_start", id, - otadiag.KV("offset", u32s(msg.Offset)), - otadiag.KV("raw_len", strconvx.Itoa(len(raw))), - ) + if fabricXferDiagEnabled("sink_write_start") { + otadiag.Event( + "[fabric-xfer]", "sink_write_start", id, + otadiag.KV("offset", u32s(msg.Offset)), + otadiag.KV("raw_len", strconvx.Itoa(len(raw))), + ) + } s.startPendingChunkWrite(cur, msg.Offset, raw) - otadiag.Event( - "[fabric-xfer]", "sink_write_pending", id, - otadiag.KV("dur_ms", int(time.Since(writeStart)/time.Millisecond)), - otadiag.KV("offset", u32s(msg.Offset)), - ) + if fabricXferDiagEnabled("sink_write_pending") { + otadiag.Event( + "[fabric-xfer]", "sink_write_pending", id, + otadiag.KV("dur_ms", int(time.Since(writeStart)/time.Millisecond)), + otadiag.KV("offset", u32s(msg.Offset)), + ) + } } func (s *session) onTransferCommit(msg *protoXferCommit) { @@ -950,13 +1122,18 @@ func (s *session) onTransferCommit(msg *protoXferCommit) { return } id := cur.meta.ID + if xferProbeEnabled { + xferProbe("commit_rx", "id", id, "bytes", cur.bytesWritten, "size", msg.Size, "digest", msg.Digest) + } if cur.pendingChunk != nil { s.markRx() - otadiag.Event( - "[fabric-xfer]", "commit_while_write_pending", id, - otadiag.KV("expected", u32s(cur.bytesWritten)), - otadiag.KV("pending_offset", u32s(cur.pendingChunk.offset)), - ) + if fabricXferDiagEnabled("commit_while_write_pending") { + otadiag.Event( + "[fabric-xfer]", "commit_while_write_pending", id, + otadiag.KV("expected", u32s(cur.bytesWritten)), + otadiag.KV("pending_offset", u32s(cur.pendingChunk.offset)), + ) + } return } if msg.Size != cur.meta.Size || cur.bytesWritten != cur.meta.Size { @@ -987,7 +1164,9 @@ func (s *session) onTransferCommit(msg *protoXferCommit) { return } s.markRx() - otadiag.Event("[fabric-xfer]", "transfer_commit_start", id) + if fabricXferDiagEnabled("transfer_commit_start") { + otadiag.Event("[fabric-xfer]", "transfer_commit_start", id) + } s.startPendingTransferCommit(cur) } @@ -1020,9 +1199,11 @@ func (s *session) startTransferTargetCall(meta transferMeta, xferID string, info sub: s.conn.Request(msg), deadline: time.Now().Add(s.cfg.TargetCallTimeout), } - otadiag.Event("[fabric-xfer]", "target_call_start", xferID, - otadiag.KV("timeout_ms", int(s.cfg.TargetCallTimeout/time.Millisecond)), - ) + if fabricXferDiagEnabled("target_call_start") { + otadiag.Event("[fabric-xfer]", "target_call_start", xferID, + otadiag.KV("timeout_ms", int(s.cfg.TargetCallTimeout/time.Millisecond)), + ) + } return "" } @@ -1039,7 +1220,9 @@ func (s *session) finishTargetCall(call *pendingTargetCall, ok bool, reason stri s.counters.TransferCompletions++ s.recordCompletedTransfer(call.meta) doneOK := s.sendTransferDone(call.xferID) - otadiag.Event("[fabric-xfer]", "done_tx", call.xferID, otadiag.KV("ok", doneOK)) + if fabricXferDiagEnabled("done_tx") { + otadiag.Event("[fabric-xfer]", "done_tx", call.xferID, otadiag.KV("ok", doneOK)) + } otadiag.StopUpdateWindow("transfer_done") return } @@ -1078,7 +1261,9 @@ func (s *session) cancelTargetCall(reason string) { } s.pendingTargetCall = nil call.info.cancelStage(reason) - otadiag.Event("[fabric-xfer]", "target_call_cancel", call.xferID, otadiag.KV("reason", reason)) + if fabricXferDiagEnabled("target_call_cancel") { + otadiag.Event("[fabric-xfer]", "target_call_cancel", call.xferID, otadiag.KV("reason", reason)) + } } func decodeStageReply(payload any) (bool, string) { diff --git a/services/fabric/transfer_sink.go b/services/fabric/transfer_sink.go index b556d75..943f417 100644 --- a/services/fabric/transfer_sink.go +++ b/services/fabric/transfer_sink.go @@ -14,17 +14,29 @@ type streamedStageSink struct { } func beginUpdaterTransfer(controller StageController, meta transferMeta) (transferSink, error) { + if fabricTraceEnabled { + println("[fabric-sink]", "begin", "xfer", meta.ID, "target", meta.Target, "size", meta.Size) + } if controller == nil { return nil, errors.New("updater_stage_controller_missing") } generation, err := controller.BeginStreamedStage(meta.ID, meta.Size) if err != nil { + if fabricTraceEnabled { + println("[fabric-sink]", "begin_error", "xfer", meta.ID, "err", err.Error()) + } return nil, err } + if fabricTraceEnabled { + println("[fabric-sink]", "begin_ok", "xfer", meta.ID, "generation", generation) + } return &streamedStageSink{controller: controller, xferID: meta.ID, generation: generation}, nil } func (s *streamedStageSink) WriteChunk(off uint32, data []byte) error { + if fabricTraceEnabled { + println("[fabric-sink]", "write", "xfer", s.xferID, "generation", s.generation, "offset", off, "len", len(data)) + } if s.closed { return errors.New("sink_closed") } @@ -32,20 +44,35 @@ func (s *streamedStageSink) WriteChunk(off uint32, data []byte) error { return errors.New("unexpected_offset") } if err := s.controller.WriteStreamedStage(s.xferID, s.generation, data); err != nil { + if fabricTraceEnabled { + println("[fabric-sink]", "write_error", "xfer", s.xferID, "err", err.Error()) + } return err } s.accepted += uint32(len(data)) + if fabricTraceEnabled { + println("[fabric-sink]", "write_ok", "xfer", s.xferID, "accepted", s.accepted) + } return nil } func (s *streamedStageSink) Commit() (transferInfo, error) { + if fabricTraceEnabled { + println("[fabric-sink]", "commit", "xfer", s.xferID, "generation", s.generation) + } if s.closed { return transferInfo{}, errors.New("sink_closed") } written, err := s.controller.CommitStreamedStage(s.xferID, s.generation) if err != nil { + if fabricTraceEnabled { + println("[fabric-sink]", "commit_error", "xfer", s.xferID, "err", err.Error()) + } return transferInfo{}, err } + if fabricTraceEnabled { + println("[fabric-sink]", "commit_ok", "xfer", s.xferID, "written", written) + } s.closed = true return transferInfo{ BytesWritten: written, diff --git a/services/fabric/xfer_probe_disabled.go b/services/fabric/xfer_probe_disabled.go new file mode 100644 index 0000000..0527fa9 --- /dev/null +++ b/services/fabric/xfer_probe_disabled.go @@ -0,0 +1,7 @@ +//go:build !fabric_xfer_probe + +package fabric + +const xferProbeEnabled = false + +func xferProbe(args ...any) {} diff --git a/services/fabric/xfer_probe_enabled.go b/services/fabric/xfer_probe_enabled.go new file mode 100644 index 0000000..7c3d4a1 --- /dev/null +++ b/services/fabric/xfer_probe_enabled.go @@ -0,0 +1,87 @@ +//go:build fabric_xfer_probe + +package fabric + +import "strconv" + +const xferProbeEnabled = true + +var xferProbeLastProgress uint32 + +func xferProbe(args ...any) { + if !xferProbeShouldPrint(args...) { + return + } + print("[fabric-xfer-probe]") + for _, a := range args { + print(" ") + switch v := a.(type) { + case string: + print(v) + case int: + print(strconv.Itoa(v)) + case uint32: + print(strconv.FormatUint(uint64(v), 10)) + case uint64: + print(strconv.FormatUint(v, 10)) + case bool: + if v { + print("true") + } else { + print("false") + } + case error: + if v != nil { + print(v.Error()) + } + default: + print("?") + } + } + println() +} + +func xferProbeShouldPrint(args ...any) bool { + if len(args) == 0 { + return false + } + event, _ := args[0].(string) + switch event { + case "chunk_rx", "write_start", "write_done": + return false + case "need_after_write": + // Progress only. Per-chunk printing materially perturbs UART RX service + // while the peer is already sending the next chunk. + next, ok := xferProbeArgUint32(args, "next") + if !ok { + return false + } + if next == 0 || next-xferProbeLastProgress >= 32768 { + xferProbeLastProgress = next + return true + } + return false + default: + return true + } +} + +func xferProbeArgUint32(args []any, key string) (uint32, bool) { + for i := 0; i+1 < len(args); i++ { + k, ok := args[i].(string) + if !ok || k != key { + continue + } + switch v := args[i+1].(type) { + case uint32: + return v, true + case int: + if v >= 0 { + return uint32(v), true + } + case uint64: + return uint32(v), true + } + } + return 0, false +} diff --git a/services/hal/devices/serial_raw/builder.go b/services/hal/devices/serial_raw/builder.go index 5726798..2616155 100644 --- a/services/hal/devices/serial_raw/builder.go +++ b/services/hal/devices/serial_raw/builder.go @@ -49,6 +49,7 @@ type session struct { rxRing *shmring.Ring txHandle shmring.Handle txRing *shmring.Ring + probe uartxProbe // Single worker (reactor) for the port. ctx context.Context @@ -317,14 +318,25 @@ func (d *Device) reactor(s *session) { u := d.port rxR := s.rxRing // UART -> app txR := s.txRing // app -> UART + s.probe.start(d.id, u, rxR, txR) for { made := false - - // UART RX -> rxRing (use spans; fill p1 completely before p2) + rxMade := false + rxBackpressure := false + + // UART RX -> rxRing. + // + // RX is the only lossy edge in this chain. Drain the UARTX software RX + // ring until it is empty, or until the session ring applies real + // back-pressure. Do not switch to TX merely because some RX bytes were + // published: during a peer chunk, the remote UART keeps sending and the + // interrupt-side ring only has short-latency elasticity. for { p1, p2 := rxR.WriteAcquire() if len(p1) == 0 { + s.probe.rxRingFull(d.id, u, rxR, txR) + rxBackpressure = true break } n1 := u.TryRead(p1) @@ -333,7 +345,9 @@ func (d *Device) reactor(s *session) { } if n1 < len(p1) { rxR.WriteCommit(n1) + s.probe.afterRX(d.id, u, rxR, txR, n1) made = true + rxMade = true continue } n2 := 0 @@ -341,36 +355,85 @@ func (d *Device) reactor(s *session) { n2 = u.TryRead(p2) } rxR.WriteCommit(n1 + n2) + s.probe.afterRX(d.id, u, rxR, txR, n1+n2) made = true + rxMade = true } - // txRing -> UART TX (use spans; drain p1 completely before p2) - for { + if rxBackpressure { + // Downstream RX is full. Do not spin on UART readability, and do not rely + // on an explicit scheduler yield. If there is no outbound work, block on + // the only two edges that can make progress: the protocol consumer freeing + // RX space, or the application producing TX work. If outbound work exists, + // allow one small TX escape hatch; this lets a writer blocked in writeLine + // finish, after which the same application goroutine can read and free + // rxRing. + made = false + if txR.Available() == 0 { + select { + case <-s.ctx.Done(): + return + case <-rxR.Writable(): + case <-txR.Readable(): + } + continue + } + } else if rxMade { + // RX made progress and the downstream ring still had room. Re-check RX + // immediately before considering TX. This preserves the serial worker + // as the short-latency drain for the UARTX ISR ring without involving a + // scheduler hint. + continue + } + + // txRing -> UART TX. Transmit under a small per-activation budget so + // retained publications or diagnostic chatter cannot monopolise this + // worker while the peer is sending a long chunk. Under RX back-pressure, + // the same budget also acts as a deadlock escape hatch for a writer whose + // reader cannot run until writeLine completes. + const txBudgetPerPass = 64 + txBudget := txBudgetPerPass + for txBudget > 0 { p1, p2 := txR.ReadAcquire() if len(p1) == 0 { break } + if len(p1) > txBudget { + p1 = p1[:txBudget] + } n1 := u.TryWrite(p1) if n1 == 0 { break } - if n1 < len(p1) { + txBudget -= n1 + if n1 < len(p1) || txBudget == 0 { txR.ReadRelease(n1) + s.probe.afterTX(d.id, u, rxR, txR, n1) made = true - continue + break } n2 := 0 - if len(p2) > 0 { + if len(p2) > 0 && txBudget > 0 { + if len(p2) > txBudget { + p2 = p2[:txBudget] + } n2 = u.TryWrite(p2) + txBudget -= n2 } txR.ReadRelease(n1 + n2) + s.probe.afterTX(d.id, u, rxR, txR, n1+n2) made = true + if n2 == 0 || txBudget == 0 { + break + } } if made { continue } + s.probe.periodic(d.id, u, rxR, txR) + // Idle: wait for any edge, then re-check. select { case <-s.ctx.Done(): diff --git a/services/hal/devices/serial_raw/uartx_probe_default.go b/services/hal/devices/serial_raw/uartx_probe_default.go new file mode 100644 index 0000000..e322140 --- /dev/null +++ b/services/hal/devices/serial_raw/uartx_probe_default.go @@ -0,0 +1,16 @@ +//go:build !uartx_probe + +package serial_raw + +import ( + "devicecode-go/services/hal/internal/core" + "devicecode-go/x/shmring" +) + +type uartxProbe struct{} + +func (p *uartxProbe) start(id string, port core.SerialPort, rxR, txR *shmring.Ring) {} +func (p *uartxProbe) afterRX(id string, port core.SerialPort, rxR, txR *shmring.Ring, n int) {} +func (p *uartxProbe) afterTX(id string, port core.SerialPort, rxR, txR *shmring.Ring, n int) {} +func (p *uartxProbe) rxRingFull(id string, port core.SerialPort, rxR, txR *shmring.Ring) {} +func (p *uartxProbe) periodic(id string, port core.SerialPort, rxR, txR *shmring.Ring) {} diff --git a/services/hal/devices/serial_raw/uartx_probe_enabled.go b/services/hal/devices/serial_raw/uartx_probe_enabled.go new file mode 100644 index 0000000..c9b0732 --- /dev/null +++ b/services/hal/devices/serial_raw/uartx_probe_enabled.go @@ -0,0 +1,117 @@ +//go:build uartx_probe + +package serial_raw + +import ( + "time" + + "devicecode-go/services/hal/internal/core" + "devicecode-go/x/shmring" +) + +type uartxProbe struct { + armed bool + nextPeriodic time.Time + nextLoss time.Time + last core.SerialDebugStats + lastLoss uint32 +} + +func (p *uartxProbe) start(id string, port core.SerialPort, rxR, txR *shmring.Ring) { + p.print("start", id, port, rxR, txR) + p.nextPeriodic = time.Now().Add(2 * time.Second) + p.armed = true +} + +func (p *uartxProbe) afterRX(id string, port core.SerialPort, rxR, txR *shmring.Ring, n int) { + if n <= 0 { + return + } + p.printIfLossChanged("rx", id, port, rxR, txR) +} + +func (p *uartxProbe) afterTX(id string, port core.SerialPort, rxR, txR *shmring.Ring, n int) { + if n <= 0 { + return + } + p.printIfLossChanged("tx", id, port, rxR, txR) +} + +func (p *uartxProbe) rxRingFull(id string, port core.SerialPort, rxR, txR *shmring.Ring) { + // This is the HAL session ring, not the UARTX ISR ring. It matters because + // if it stays full, the session worker cannot drain the UARTX RX ring. + if rxR.Space() == 0 { + p.print("session_rx_ring_full", id, port, rxR, txR) + } +} + +func (p *uartxProbe) periodic(id string, port core.SerialPort, rxR, txR *shmring.Ring) { + now := time.Now() + if !p.armed || now.After(p.nextPeriodic) { + p.print("periodic", id, port, rxR, txR) + p.nextPeriodic = now.Add(2 * time.Second) + p.armed = true + } +} + +func (p *uartxProbe) printIfLossChanged(reason, id string, port core.SerialPort, rxR, txR *shmring.Ring) { + s, ok := debugStats(port) + if !ok { + return + } + loss := totalLoss(s) + // Keep the probe diagnostic but not self-defeating. Printing every dropped + // byte can itself stall the serial pump and create more drops. Emit promptly + // for the first loss edge, then coalesce further loss changes by count or + // time; max-occupancy changes are still visible in periodic snapshots. + now := time.Now() + if loss != p.lastLoss && (p.lastLoss == 0 || loss-p.lastLoss >= 128 || now.After(p.nextLoss)) { + p.print(reason, id, port, rxR, txR) + p.nextLoss = now.Add(500 * time.Millisecond) + } +} + +func totalLoss(s core.SerialDebugStats) uint32 { + return s.RXRingDrops + s.RXOverrun + s.RXBreak + s.RXParity + s.RXFraming +} + +func debugStats(port core.SerialPort) (core.SerialDebugStats, bool) { + d, ok := port.(core.SerialDiagnostics) + if !ok { + return core.SerialDebugStats{}, false + } + return d.DebugStats(), true +} + +func (p *uartxProbe) print(reason, id string, port core.SerialPort, rxR, txR *shmring.Ring) { + s, ok := debugStats(port) + if !ok { + return + } + loss := totalLoss(s) + println("[uartx-probe]", id, + "reason", reason, + "rx_hw", s.RXHWBytes, + "rx_enq", s.RXEnqueued, + "rx_read", s.RXReadBytes, + "rx_drop", s.RXRingDrops, + "rx_oe", s.RXOverrun, + "rx_fe", s.RXFraming, + "rx_pe", s.RXParity, + "rx_be", s.RXBreak, + "rx_max", s.RXRingMax, + "rx_notify_drop", s.RXNotifyDrop, + "tx_acc", s.TXAccepted, + "tx_hw", s.TXHWBytes, + "tx_full", s.TXRingFull, + "tx_max", s.TXRingMax, + "tx_notify_drop", s.TXNotifyDrop, + "sess_rx_avail", rxR.Available(), + "sess_rx_space", rxR.Space(), + "sess_tx_avail", txR.Available(), + "sess_tx_space", txR.Space(), + "loss", loss, + ) + p.last = s + p.lastLoss = loss +} diff --git a/services/hal/internal/core/resources.go b/services/hal/internal/core/resources.go index f589b24..84e951b 100644 --- a/services/hal/internal/core/resources.go +++ b/services/hal/internal/core/resources.go @@ -127,6 +127,36 @@ type SerialFormatConfigurator interface { SetFormat(databits, stopbits uint8, parity string) error } +// SerialDebugStats is an optional provider-specific diagnostic snapshot for +// UART-like serial resources. Values are coarse counters used to attribute data +// loss during hardware tests; they are not part of the stable HAL contract. +type SerialDebugStats struct { + RXIRQ uint32 + RXHWBytes uint32 + RXEnqueued uint32 + RXRingDrops uint32 + RXOverrun uint32 + RXBreak uint32 + RXParity uint32 + RXFraming uint32 + RXRingMax uint32 + RXReadBytes uint32 + RXReadEmpty uint32 + RXNotifyDrop uint32 + + TXIRQ uint32 + TXAccepted uint32 + TXHWBytes uint32 + TXRingFull uint32 + TXRingMax uint32 + TXTryCalls uint32 + TXNotifyDrop uint32 +} + +type SerialDiagnostics interface { + DebugStats() SerialDebugStats +} + // ---- Unified registry interface ---- type ResourceRegistry interface { diff --git a/services/hal/internal/provider/rp2_resources.go b/services/hal/internal/provider/rp2_resources.go index 28e24e6..8f08b72 100644 --- a/services/hal/internal/provider/rp2_resources.go +++ b/services/hal/internal/provider/rp2_resources.go @@ -707,9 +707,23 @@ type rp2SerialPort struct{ u *uartx.UART } func (p *rp2SerialPort) Readable() <-chan struct{} { return p.u.Readable() } func (p *rp2SerialPort) Writable() <-chan struct{} { return p.u.Writable() } -func (p *rp2SerialPort) TryRead(b []byte) int { return p.u.TryRead(b) } -func (p *rp2SerialPort) TryWrite(b []byte) int { return p.u.TryWrite(b) } -func (p *rp2SerialPort) Flush() error { return p.u.Flush() } +func (p *rp2SerialPort) TryRead(b []byte) int { + n := p.u.TryRead(b) + p.u.NoteRXRead(n) + return n +} +func (p *rp2SerialPort) TryWrite(b []byte) int { return p.u.TryWrite(b) } +func (p *rp2SerialPort) Flush() error { return p.u.Flush() } +func (p *rp2SerialPort) DebugStats() core.SerialDebugStats { + s := p.u.Stats() + return core.SerialDebugStats{ + RXIRQ: s.RXIRQ, RXHWBytes: s.RXHWBytes, RXEnqueued: s.RXEnqueued, RXRingDrops: s.RXRingDrops, + RXOverrun: s.RXOverrun, RXBreak: s.RXBreak, RXParity: s.RXParity, RXFraming: s.RXFraming, + RXRingMax: s.RXRingMax, RXReadBytes: s.RXReadBytes, RXReadEmpty: s.RXReadEmpty, RXNotifyDrop: s.RXNotifyDrop, + TXIRQ: s.TXIRQ, TXAccepted: s.TXAccepted, TXHWBytes: s.TXHWBytes, TXRingFull: s.TXRingFull, + TXRingMax: s.TXRingMax, TXTryCalls: s.TXTryCalls, TXNotifyDrop: s.TXNotifyDrop, + } +} func (p *rp2SerialPort) SetBaudRate(br uint32) error { p.u.SetBaudRate(br); return nil } diff --git a/services/hal/internal/provider/setup_none.go b/services/hal/internal/provider/setup_none.go index 2863103..77e6ecf 100644 --- a/services/hal/internal/provider/setup_none.go +++ b/services/hal/internal/provider/setup_none.go @@ -1,4 +1,4 @@ -//go:build !((rp2040 || rp2350) && (pico_rich_dev || pico_bb_proto_1)) +//go:build !((rp2040 || rp2350) && (pico_rich_dev || pico_bb_proto_1 || pico_cm5_emulator)) package provider diff --git a/services/hal/internal/provider/setup_selected.go b/services/hal/internal/provider/setup_selected.go index f865eef..20a4e6e 100644 --- a/services/hal/internal/provider/setup_selected.go +++ b/services/hal/internal/provider/setup_selected.go @@ -1,4 +1,4 @@ -//go:build (rp2040 || rp2350) && (pico_rich_dev || pico_bb_proto_1) +//go:build (rp2040 || rp2350) && (pico_rich_dev || pico_bb_proto_1 || pico_cm5_emulator) package provider diff --git a/services/hal/internal/provider/setups/pico_bb_proto_1.go b/services/hal/internal/provider/setups/pico_bb_proto_1.go index 58a8101..df47893 100644 --- a/services/hal/internal/provider/setups/pico_bb_proto_1.go +++ b/services/hal/internal/provider/setups/pico_bb_proto_1.go @@ -24,6 +24,11 @@ var SelectedPlan = ResourcePlan{ }, } +// Keep raw serial session rings deliberately modest. Fabric is a framed +// stream protocol and should remain correct under bounded buffering; these +// are not intended to hold full transfer frames. +const rawSerialSessionSize = 512 + var SelectedSetup = types.HALConfig{ Devices: []types.HALDevice{ @@ -48,8 +53,8 @@ var SelectedSetup = types.HALConfig{ Domain: "io", Name: "uart0", Baud: 115_200, - RXSize: 32, - TXSize: 2048, + RXSize: rawSerialSessionSize, + TXSize: rawSerialSessionSize, }}, // Raw serial device bound to uart1 (public address hal/cap/io/serial/uart1/…) @@ -58,8 +63,8 @@ var SelectedSetup = types.HALConfig{ Domain: "io", Name: "uart1", Baud: 115_200, - RXSize: 32, - TXSize: 512, + RXSize: rawSerialSessionSize, + TXSize: rawSerialSessionSize, }}, {ID: "charger0", Type: "ltc4015", Params: ltc4015dev.Params{ diff --git a/services/hal/internal/provider/setups/pico_cm5_emulator.go b/services/hal/internal/provider/setups/pico_cm5_emulator.go new file mode 100644 index 0000000..a91447b --- /dev/null +++ b/services/hal/internal/provider/setups/pico_cm5_emulator.go @@ -0,0 +1,34 @@ +//go:build (rp2040 || rp2350) && pico_cm5_emulator + +package setups + +import ( + serialraw "devicecode-go/services/hal/devices/serial_raw" + "devicecode-go/types" +) + +var SelectedPlan = ResourcePlan{ + UART: []UARTPlan{ + // Pico 1 CM5 emulator link UART. + // Wire Pico 1 GP0/TX -> Pico 2 GP5/RX and Pico 1 GP1/RX <- Pico 2 GP4/TX. + {ID: "uart0", TX: 0, RX: 1, Baud: 115_200}, + }, +} + +// Keep the emulator link under the same bounded serial-session constraint as +// the MCU Fabric link; the emulator must stream and apply flow control rather +// than buffering whole Fabric frames in the HAL session. +const rawSerialSessionSize = 512 + +var SelectedSetup = types.HALConfig{ + Devices: []types.HALDevice{ + {ID: "uart0_raw", Type: "serial_raw", Params: serialraw.Params{ + Bus: "uart0", + Domain: "io", + Name: "uart0", + Baud: 115_200, + RXSize: rawSerialSessionSize, + TXSize: rawSerialSessionSize, + }}, + }, +} diff --git a/services/hal/internal/provider/setups/pico_rich_dev.go b/services/hal/internal/provider/setups/pico_rich_dev.go index d2bd8e8..ddbefb2 100644 --- a/services/hal/internal/provider/setups/pico_rich_dev.go +++ b/services/hal/internal/provider/setups/pico_rich_dev.go @@ -23,6 +23,11 @@ var SelectedPlan = ResourcePlan{ }, } +// Keep raw serial session rings deliberately modest. Fabric is a framed +// stream protocol and should remain correct under bounded buffering; these +// are not intended to hold full transfer frames. +const rawSerialSessionSize = 512 + var SelectedSetup = types.HALConfig{ Devices: []types.HALDevice{ @@ -42,8 +47,8 @@ var SelectedSetup = types.HALConfig{ Domain: "io", Name: "uart0", Baud: 115_200, - RXSize: 32, - TXSize: 2048, + RXSize: rawSerialSessionSize, + TXSize: rawSerialSessionSize, }}, // Raw serial device bound to uart1 (public address hal/cap/io/serial/uart1/…) @@ -52,8 +57,8 @@ var SelectedSetup = types.HALConfig{ Domain: "io", Name: "uart1", Baud: 115_200, - RXSize: 32, - TXSize: 512, + RXSize: rawSerialSessionSize, + TXSize: rawSerialSessionSize, }}, {ID: "charger0", Type: "ltc4015", Params: ltc4015dev.Params{ diff --git a/services/otadiag/otadiag.go b/services/otadiag/otadiag.go index f156484..b6407fe 100644 --- a/services/otadiag/otadiag.go +++ b/services/otadiag/otadiag.go @@ -41,6 +41,13 @@ func KV(key string, value any) Field { return Field{Key: key, Value: valueString(value)} } +// Enabled reports whether Event would emit a line for prefix/event under the +// current verbosity policy. It is used by TinyGo hot paths to avoid building +// diagnostic Field values that would be filtered out. +func Enabled(prefix, event string) bool { + return allowEvent(prefix, event, nil) +} + func Event(prefix, event, xferID string, fields ...Field) { if !allowEvent(prefix, event, fields) { return diff --git a/services/otadiag/verbose_trace.go b/services/otadiag/verbose_trace.go new file mode 100644 index 0000000..10b0232 --- /dev/null +++ b/services/otadiag/verbose_trace.go @@ -0,0 +1,7 @@ +//go:build ota_trace + +package otadiag + +func init() { + verbose.Store(true) +} diff --git a/services/updater/stream_lease.go b/services/updater/stream_lease.go index 9f65ab6..2adb43c 100644 --- a/services/updater/stream_lease.go +++ b/services/updater/stream_lease.go @@ -101,6 +101,9 @@ func (s *Service) CancelStreamedStage(xferID string, generation uint64, reason s } func (s *Service) submitStreamedStageCommand(cmd streamedStageCommand) streamedStageCommandResult { + if updaterTraceEnabled { + println("[updater-trace]", "submit", "kind", int(cmd.kind), "xfer", cmd.xferID, "generation", cmd.generation, "size", cmd.size, "data_len", len(cmd.data)) + } if s == nil { return streamedStageCommandResult{err: errors.New("updater_not_running")} } @@ -122,6 +125,13 @@ func (s *Service) submitStreamedStageCommand(cmd streamedStageCommand) streamedS } select { case res := <-cmd.reply: + if updaterTraceEnabled { + errText := "" + if res.err != nil { + errText = res.err.Error() + } + println("[updater-trace]", "reply", "kind", int(cmd.kind), "xfer", cmd.xferID, "generation", res.generation, "written", res.written, "err", errText) + } return res case <-s.stageStopped: return streamedStageCommandResult{err: errors.New("updater_not_running")} @@ -141,6 +151,9 @@ func (s *Service) runStreamedStageWorker(ctx context.Context) { s.clearActiveABUpdateDiagHook() return } + if updaterTraceEnabled { + println("[updater-trace]", "worker_start", "kind", int(cmd.kind), "xfer", cmd.xferID, "generation", cmd.generation, "size", cmd.size, "data_len", len(cmd.data)) + } res := streamedStageWorkerResult{kind: cmd.kind, xferID: cmd.xferID, generation: cmd.generation} switch cmd.kind { case streamedStageCommandBegin: @@ -155,6 +168,13 @@ func (s *Service) runStreamedStageWorker(ctx context.Context) { default: res.err = errors.New("bad_stage_command") } + if updaterTraceEnabled { + errText := "" + if res.err != nil { + errText = res.err.Error() + } + println("[updater-trace]", "worker_done", "kind", int(cmd.kind), "xfer", cmd.xferID, "generation", cmd.generation, "err", errText) + } select { case s.stageWorkerResults <- res: case <-ctx.Done(): @@ -167,6 +187,9 @@ func (s *Service) runStreamedStageWorker(ctx context.Context) { } func (s *Service) handleStreamedStageCommand(cmd streamedStageCommand) { + if updaterTraceEnabled { + println("[updater-trace]", "handle_cmd", "kind", int(cmd.kind), "xfer", cmd.xferID, "generation", cmd.generation, "pending", s.pendingStageCommand != nil) + } if cmd.reply == nil { return } @@ -208,6 +231,9 @@ func (s *Service) cancelPendingStreamedStage(cmd streamedStageCommand) { } func (s *Service) startStreamedStageBegin(cmd streamedStageCommand) { + if updaterTraceEnabled { + println("[updater-trace]", "begin_cmd", "xfer", cmd.xferID, "size", cmd.size) + } beginAt := time.Now() otadiag.SetActiveXfer(cmd.xferID) otadiag.Event("[updater-stream]", "begin_entry", cmd.xferID, otadiag.KV("size", cmd.size)) @@ -234,6 +260,9 @@ func (s *Service) startStreamedStageBegin(cmd streamedStageCommand) { } func (s *Service) startStreamedStageWrite(cmd streamedStageCommand) { + if updaterTraceEnabled { + println("[updater-trace]", "write_cmd", "xfer", cmd.xferID, "generation", cmd.generation, "data_len", len(cmd.data)) + } if err := s.checkStreamedStageLease(cmd.xferID, cmd.generation, false); err != nil { cmd.reply <- streamedStageCommandResult{err: err} return @@ -248,6 +277,9 @@ func (s *Service) startStreamedStageWrite(cmd streamedStageCommand) { } func (s *Service) startStreamedStageCommit(cmd streamedStageCommand) { + if updaterTraceEnabled { + println("[updater-trace]", "commit_cmd", "xfer", cmd.xferID, "generation", cmd.generation) + } if err := s.checkStreamedStageLease(cmd.xferID, cmd.generation, false); err != nil { cmd.reply <- streamedStageCommandResult{err: err} return @@ -282,6 +314,9 @@ func (s *Service) startStreamedStageAbort(cmd streamedStageCommand) { } func (s *Service) sendStageWorkerCommand(cmd streamedStageWorkerCommand) { + if updaterTraceEnabled { + println("[updater-trace]", "worker_queue", "kind", int(cmd.kind), "xfer", cmd.xferID, "generation", cmd.generation, "size", cmd.size, "data_len", len(cmd.data)) + } select { case s.stageWorkerCommands <- cmd: default: @@ -301,6 +336,13 @@ func (s *Service) sendStageWorkerCommand(cmd streamedStageWorkerCommand) { } func (s *Service) handleStreamedStageWorkerResult(res streamedStageWorkerResult) { + if updaterTraceEnabled { + errText := "" + if res.err != nil { + errText = res.err.Error() + } + println("[updater-trace]", "worker_result", "kind", int(res.kind), "xfer", res.xferID, "generation", res.generation, "err", errText) + } cmd := s.pendingStageCommand if cmd == nil || cmd.xferID != res.xferID || cmd.generation != res.generation || cmd.kind != res.kind { // Stale worker result from an already-cancelled generation. The updater @@ -324,6 +366,13 @@ func (s *Service) handleStreamedStageWorkerResult(res streamedStageWorkerResult) } func (s *Service) finishStreamedStageBegin(cmd streamedStageCommand, res streamedStageWorkerResult) { + if updaterTraceEnabled { + errText := "" + if res.err != nil { + errText = res.err.Error() + } + println("[updater-trace]", "finish_begin", "xfer", cmd.xferID, "generation", cmd.generation, "err", errText) + } beginAt := time.Now() if res.err != nil { clearABUpdateDiagHookFor(cmd.xferID, cmd.generation) @@ -361,6 +410,13 @@ func (s *Service) finishStreamedStageBegin(cmd streamedStageCommand, res streame } func (s *Service) finishStreamedStageWrite(cmd streamedStageCommand, res streamedStageWorkerResult) { + if updaterTraceEnabled { + errText := "" + if res.err != nil { + errText = res.err.Error() + } + println("[updater-trace]", "finish_write", "xfer", cmd.xferID, "generation", cmd.generation, "err", errText) + } if res.err != nil { s.cancelStreamedStageLease(cmd.xferID, cmd.generation, res.err.Error()) clearABUpdateDiagHookFor(cmd.xferID, cmd.generation) @@ -378,6 +434,13 @@ func (s *Service) finishStreamedStageWrite(cmd streamedStageCommand, res streame } func (s *Service) finishStreamedStageCommit(cmd streamedStageCommand, res streamedStageWorkerResult) { + if updaterTraceEnabled { + errText := "" + if res.err != nil { + errText = res.err.Error() + } + println("[updater-trace]", "finish_commit", "xfer", cmd.xferID, "generation", cmd.generation, "err", errText, "staged_len", res.staged.Length) + } clearABUpdateDiagHookFor(cmd.xferID, cmd.generation) if res.err != nil { s.cancelStreamedStageLease(cmd.xferID, cmd.generation, res.err.Error()) diff --git a/services/updater/trace_disabled.go b/services/updater/trace_disabled.go new file mode 100644 index 0000000..3790bb1 --- /dev/null +++ b/services/updater/trace_disabled.go @@ -0,0 +1,5 @@ +//go:build !updater_trace + +package updater + +const updaterTraceEnabled = false diff --git a/services/updater/trace_enabled.go b/services/updater/trace_enabled.go new file mode 100644 index 0000000..c40f634 --- /dev/null +++ b/services/updater/trace_enabled.go @@ -0,0 +1,5 @@ +//go:build updater_trace + +package updater + +const updaterTraceEnabled = true diff --git a/third_party/tinygo-uartx/README.md b/third_party/tinygo-uartx/README.md deleted file mode 100644 index 83d52c5..0000000 --- a/third_party/tinygo-uartx/README.md +++ /dev/null @@ -1,239 +0,0 @@ -# tinygo-uartx - -Interrupt-driven UART for TinyGo with **blocking `io.Reader`/`io.Writer` semantics**, explicit non-blocking operations, and practical flush. Designed and tested on RP2040/RP2350 (PL011), with stubs for other TinyGo targets. - -> **Compatibility notes** -> * `uartx` **breaks** with TinyGo’s `machine.UART` behaviour: **`Read(p)` blocks** until at least one byte is available. If you need non-blocking reads, use `TryRead`. See “API differences” below. -> * **TinyGo 0.39:** this currently requires the single-core scheduler. Build with `-scheduler tasks`. - ---- - -## Why use `uartx`? - -* **Correct, event-driven TX/RX**: uses hardware FIFOs and IRQs; foreground code does not poll during normal operation. -* **Clear Go semantics**: implements `io.Reader`, `io.Writer`, `io.ByteReader`, `io.ByteWriter` (via `ReadByte`/`WriteByte`) and a simple `Flusher` (`Flush()`). -* **Concurrent composability**: coalesced readiness channels `Readable()` and `Writable()` integrate naturally with `select` for fast, back-pressure-aware pacing. -* **On-the-wire completion**: `Flush()` waits for software buffer empty **and** hardware FIFO empty **and** the transmitter to go idle. -* **Production-oriented**: liveness at TX start, no foreground/ISR races, correct ordering of RX error handling, and minimal timed polling only where the hardware offers no interrupt (TX idle). - ---- - -## Supported targets - -* RP2040 / RP2350 (`go:build rp2040 || rp2350`) with PL011. - Other arches are build-gated but not implemented here. - ---- - -## Install - -```bash -go get github.com/jangala-dev/tinygo-uartx/uartx -``` - ---- - -## Quick start (RP2040) - -```go -package main - -import ( - "machine" - "time" - - "github.com/jangala-dev/tinygo-uartx/uartx" -) - -func main() { - u0 := uartx.UART0 - u1 := uartx.UART1 - - // Wire: U0 TX=GP0 -> U1 RX=GP5, U1 TX=GP4 -> U0 RX=GP1 - _ = u0.Configure(uartx.UARTConfig{BaudRate: 230400, TX: machine.Pin(0), RX: machine.Pin(1)}) - _ = u1.Configure(uartx.UARTConfig{BaudRate: 230400, TX: machine.Pin(4), RX: machine.Pin(5)}) - - // Writer: block until bytes are accepted by driver (SW TX ring and/or HW FIFO). - msg := []byte("hello, world\n") - _, _ = u0.Write(msg) - - // Reader: Read blocks until at least 1 byte is available, returns n>0, nil. - buf := make([]byte, 64) - n, _ := u1.Read(buf) - _, _ = u1.Write(buf[:n]) // echo back - - // Ensure everything went on the wire. - _ = u0.Flush() - _ = u1.Flush() - - for { time.Sleep(time.Second) } -} -``` - ---- - -## API overview - -### Blocking I/O - -* `Read(p []byte) (int, error)` - Blocks until **at least one byte** is available; returns `n>0, nil`. Does **not** return `io.EOF` for an idle UART. - -* `Write(p []byte) (int, error)` - Blocks until **all bytes are accepted** by the driver (software TX buffer and/or HW FIFO). Does **not** wait for the line to drain; see `Flush`. - -* `ReadByte() (byte, error)` - Non-blocking single-byte read from the software RX buffer. Returns `errUARTBufferEmpty` if no data is available. - -* `WriteByte(b byte) error` - Blocks until the byte is accepted by the driver. - -### Non-blocking helpers - -* `TryRead(p []byte) int` - Returns immediately with up to `len(p)` bytes from the RX buffer. `0` means “no data now”. -* `TryWrite(p []byte) int` - Returns immediately with `0..len(p)` bytes accepted into HW FIFO and/or SW TX buffer. `0` means “no space now”. - -### Readiness (for `select`) - -* `Readable() <-chan struct{}` - Coalesced notification: a receive interrupt that enqueues ≥1 byte sends a token. You **must re-check** state after waking (level→edge coalescer). -* `Writable() <-chan struct{}` - Coalesced notification: TX progress/space. Sent when bytes move SW→HW or space appears. Also level-coalesced; re-check state after waking. - -### Flush - -* `Flush() error` - Blocks until software TX buffer is empty, the HW TX FIFO is empty, **and** the transmitter is not busy. - Note: PL011 does not raise an interrupt for the final “idle” edge, so `Flush` uses a short timed poll (scaled to baud) in addition to readiness wakes. - -### Buffer introspection - -* `Buffered() int` – bytes in the RX ring. -* `TxFree() int` – free space in the SW TX ring. - -### Interfaces satisfied - -* `io.Reader`, `io.Writer`, `io.ByteReader`, `io.ByteWriter` -* `Flusher` (package-local `Flush() error`) - ---- - -## API differences vs TinyGo `machine.UART` - -| Behaviour | `machine.UART` (TinyGo) | `uartx` | -| ---------------------- | ------------------------ | --------------------------------------------- | -| `Read(p)` | **Non-blocking** | **Blocking** until ≥1 byte | -| Non-blocking read | `Read(p)` | `TryRead(p) int` | -| Non-blocking write | implementation-dependent | `TryWrite(p) int` | -| Event readiness | varied | `Readable()`, `Writable()` coalesced channels | -| On-the-wire completion | `Write(p)` | `Flush()` (FIFO empty **and** line idle) | -| Internals | polling/IRQ mix | **IRQ-driven**; HW FIFOs; minimal timed poll | - -If you migrate from `machine.UART`, audit any paths that relied on `Read` being non-blocking. Use `TryRead` or `Readable()` with `select` for non-blocking behaviour. - ---- - -## Concurrent patterns - -### Producer with pacing - -```go -func writeAll(u *uartx.UART, p []byte) { - sent := 0 - for sent < len(p) { - if n := u.TryWrite(p[sent:]); n > 0 { - sent += n - continue - } - <-u.Writable() // wait for space/progress; then re-check - } -} -``` - -### Consumer with timeout - -```go -func readSome(ctx context.Context, u *uartx.UART, p []byte) (int, error) { - if n := u.TryRead(p); n > 0 { return n, nil } - for { - select { - case <-u.Readable(): - if n := u.TryRead(p); n > 0 { return n, nil } - case <-ctx.Done(): - return 0, ctx.Err() - } - } -} -``` - -### Duplex with `select` - -```go -func pump(uIn, uOut *uartx.UART, buf []byte) { - for { - select { - case <-uIn.Readable(): - if n := uIn.TryRead(buf); n > 0 { - writeAll(uOut, buf[:n]) - } - case <-uOut.Writable(): - // optional: send pending application data - } - } -} -``` - ---- - -## Behavioural notes (RP2040/RP2350) - -* **Interrupt model**: RX uses level/timeout; TX uses level. Steady-state writes to the HW FIFO are performed in the ISR. Foreground only seeds the FIFO at TX start or in a guarded “masked kick” corner case; this avoids reordering. -* **Error handling**: framing/parity/overrun bytes are dropped on RX (read clears per-byte flags); sticky error status is cleared after draining. -* **Flush**: requires SW TX empty, FIFO empty and transmitter not busy. The final idle edge is not interrupt-driven on PL011; a short timed poll is used. - ---- - -## Example: integrity test (excerpt) - -```go -// Sender -func sendPattern(ctx context.Context, u *uartx.UART, gen func(int) byte, n int) error { - var buf [192]byte - for i := 0; i < n; { - k := len(buf) - if n-i < k { k = n - i } - for j := 0; j < k; j++ { buf[j] = gen(i+j) } - if _, err := sendAll(ctx, u, buf[:k]); err != nil { return err } - i += k - } - return nil -} - -func sendAll(ctx context.Context, u *uartx.UART, p []byte) (int, error) { - sent := 0 - for sent < len(p) { - if n := u.TryWrite(p[sent:]); n > 0 { sent += n; continue } - select { - case <-u.Writable(): - case <-ctx.Done(): return sent, ctx.Err() - } - } - return sent, nil -} -``` - ---- - -## Limitations and future work - -* Only RP2040/RP2350 implementation is included at present. -* RX overflow is dropped silently by default; add counters if required for diagnostics. -* CTS/RTS flow control is enabled only if both pins are configured; application-level tests advised. - ---- - -## Licence - -MIT, see `LICENCE` file. diff --git a/third_party/tinygo-uartx/uartx/rp2_uart.go b/third_party/tinygo-uartx/uartx/rp2_uart.go index 9a6b974..f2c8be6 100644 --- a/third_party/tinygo-uartx/uartx/rp2_uart.go +++ b/third_party/tinygo-uartx/uartx/rp2_uart.go @@ -40,11 +40,106 @@ type UART struct { baud uint32 // last configured baud (for diagnostics, not used by HW) - rxDrops volatile.Register32 - rxOverruns volatile.Register32 - rxBreaks volatile.Register32 - rxParity volatile.Register32 - rxFraming volatile.Register32 + stats uartStatsRegs +} + +// UARTStats is a non-atomic diagnostic snapshot. It is intended for coarse +// attribution while testing embedded UART paths, not for accounting-critical +// decisions. Counters may be sampled while the ISR is updating them. +type UARTStats struct { + RXIRQ uint32 + RXHWBytes uint32 + RXEnqueued uint32 + RXRingDrops uint32 + RXOverrun uint32 + RXBreak uint32 + RXParity uint32 + RXFraming uint32 + RXRingMax uint32 + RXReadBytes uint32 + RXReadEmpty uint32 + RXNotifyDrop uint32 + + TXIRQ uint32 + TXAccepted uint32 + TXHWBytes uint32 + TXRingFull uint32 + TXRingMax uint32 + TXTryCalls uint32 + TXNotifyDrop uint32 +} + +type uartStatsRegs struct { + rxIRQ volatile.Register32 + rxHWBytes volatile.Register32 + rxEnqueued volatile.Register32 + rxRingDrops volatile.Register32 + rxOverrun volatile.Register32 + rxBreak volatile.Register32 + rxParity volatile.Register32 + rxFraming volatile.Register32 + rxRingMax volatile.Register32 + rxReadBytes volatile.Register32 + rxReadEmpty volatile.Register32 + rxNotifyDrop volatile.Register32 + + txIRQ volatile.Register32 + txAccepted volatile.Register32 + txHWBytes volatile.Register32 + txRingFull volatile.Register32 + txRingMax volatile.Register32 + txTryCalls volatile.Register32 + txNotifyDrop volatile.Register32 +} + +func (uart *UART) inc(reg *volatile.Register32, n uint32) { reg.Set(reg.Get() + n) } + +func (uart *UART) observeRXRingUsed() { + u := uint32(uart.Buffer.Used()) + for { + old := uart.stats.rxRingMax.Get() + if u <= old { + return + } + uart.stats.rxRingMax.Set(u) + return + } +} + +func (uart *UART) observeTXRingUsed() { + u := uint32(uart.TxBuffer.Used()) + for { + old := uart.stats.txRingMax.Get() + if u <= old { + return + } + uart.stats.txRingMax.Set(u) + return + } +} + +// Stats returns a diagnostic snapshot of UART ISR and foreground counters. +func (uart *UART) Stats() UARTStats { + return UARTStats{ + RXIRQ: uart.stats.rxIRQ.Get(), RXHWBytes: uart.stats.rxHWBytes.Get(), RXEnqueued: uart.stats.rxEnqueued.Get(), RXRingDrops: uart.stats.rxRingDrops.Get(), RXOverrun: uart.stats.rxOverrun.Get(), RXBreak: uart.stats.rxBreak.Get(), RXParity: uart.stats.rxParity.Get(), RXFraming: uart.stats.rxFraming.Get(), RXRingMax: uart.stats.rxRingMax.Get(), RXReadBytes: uart.stats.rxReadBytes.Get(), RXReadEmpty: uart.stats.rxReadEmpty.Get(), RXNotifyDrop: uart.stats.rxNotifyDrop.Get(), + TXIRQ: uart.stats.txIRQ.Get(), TXAccepted: uart.stats.txAccepted.Get(), TXHWBytes: uart.stats.txHWBytes.Get(), TXRingFull: uart.stats.txRingFull.Get(), TXRingMax: uart.stats.txRingMax.Get(), TXTryCalls: uart.stats.txTryCalls.Get(), TXNotifyDrop: uart.stats.txNotifyDrop.Get(), + } +} + +// NoteRXRead records bytes drained from the UARTX software RX ring by a +// foreground consumer. It is diagnostic only; it deliberately does not alter +// UART data state. +func (uart *UART) NoteRXRead(n int) { + if n > 0 { + uart.inc(&uart.stats.rxReadBytes, uint32(n)) + return + } + uart.inc(&uart.stats.rxReadEmpty, 1) +} + +// ClearStats resets diagnostic counters. It does not alter UART data state. +func (uart *UART) ClearStats() { + uart.stats = uartStatsRegs{} } // Configure sets up the PL011, its pins and interrupts. It leaves RXIM/RTIM @@ -168,21 +263,6 @@ func (uart *UART) SetFormat(databits, stopbits uint8, parity UARTParity) error { return nil } -// RXDropCount reports bytes dropped because the software RX ring was full. -func (uart *UART) RXDropCount() uint32 { return uart.rxDrops.Get() } - -// RXOverrunCount reports PL011 RX overrun error bytes seen by the ISR. -func (uart *UART) RXOverrunCount() uint32 { return uart.rxOverruns.Get() } - -// RXBreakCount reports PL011 RX break error bytes seen by the ISR. -func (uart *UART) RXBreakCount() uint32 { return uart.rxBreaks.Get() } - -// RXParityCount reports PL011 RX parity error bytes seen by the ISR. -func (uart *UART) RXParityCount() uint32 { return uart.rxParity.Get() } - -// RXFramingCount reports PL011 RX framing error bytes seen by the ISR. -func (uart *UART) RXFramingCount() uint32 { return uart.rxFraming.Get() } - // initUART asserts and releases the peripheral reset for the selected PL011. func initUART(uart *UART) { var resetVal uint32 @@ -230,6 +310,7 @@ func (uart *UART) attemptSend(p []byte) int { break } uart.Bus.UARTDR.Set(uint32(b)) + uart.inc(&uart.stats.txHWBytes, 1) } // Arm TX interrupts; ISR takes over steady-state. uart.Bus.UARTIMSC.SetBits(rp.UART0_UARTIMSC_TXIM) @@ -252,6 +333,7 @@ func (uart *UART) attemptSend(p []byte) int { break } uart.Bus.UARTDR.Set(uint32(b)) + uart.inc(&uart.stats.txHWBytes, 1) } // Re-enable TX level interrupts. Next drop to/under IFLS will raise TX IRQ. @@ -286,6 +368,7 @@ func (uart *UART) tryWriteHW(p []byte) int { i := 0 for i < len(p) && !uart.Bus.UARTFR.HasBits(rp.UART0_UARTFR_TXFF) { uart.Bus.UARTDR.Set(uint32(p[i])) + uart.inc(&uart.stats.txHWBytes, 1) i++ } return i @@ -296,9 +379,11 @@ func (uart *UART) enqueueTX(p []byte) int { i := 0 for i < len(p) { if ok := uart.TxBuffer.Put(p[i]); !ok { + uart.inc(&uart.stats.txRingFull, 1) break } i++ + uart.observeTXRingUsed() } return i } @@ -318,22 +403,36 @@ func (uart *UART) handleInterrupt(interrupt.Interrupt) { // RX path (RX level or RX timeout). if (mis & (rp.UART0_UARTMIS_RXMIS | rp.UART0_UARTMIS_RTMIS)) != 0 { + uart.inc(&uart.stats.rxIRQ, 1) // In the ISR, only notify if at least one byte was enqueued. enq := 0 for !uart.Bus.UARTFR.HasBits(rp.UART0_UARTFR_RXFE) { r := uart.Bus.UARTDR.Get() - errs := r & (rp.UART0_UARTDR_OE | rp.UART0_UARTDR_BE | - rp.UART0_UARTDR_PE | rp.UART0_UARTDR_FE) - if errs != 0 { - uart.noteRXErrors(errs) + uart.inc(&uart.stats.rxHWBytes, 1) + if (r & (rp.UART0_UARTDR_OE | rp.UART0_UARTDR_BE | + rp.UART0_UARTDR_PE | rp.UART0_UARTDR_FE)) != 0 { + if (r & rp.UART0_UARTDR_OE) != 0 { + uart.inc(&uart.stats.rxOverrun, 1) + } + if (r & rp.UART0_UARTDR_BE) != 0 { + uart.inc(&uart.stats.rxBreak, 1) + } + if (r & rp.UART0_UARTDR_PE) != 0 { + uart.inc(&uart.stats.rxParity, 1) + } + if (r & rp.UART0_UARTDR_FE) != 0 { + uart.inc(&uart.stats.rxFraming, 1) + } // Drop errored byte; reading DR clears the per-byte error flags. continue } if uart.Buffer.Put(byte(r & 0xFF)) { enq++ + uart.inc(&uart.stats.rxEnqueued, 1) + uart.observeRXRingUsed() } else { - incrementRegister32(&uart.rxDrops) + uart.inc(&uart.stats.rxRingDrops, 1) } } @@ -346,12 +445,14 @@ func (uart *UART) handleInterrupt(interrupt.Interrupt) { select { case uart.notify <- struct{}{}: default: + uart.inc(&uart.stats.rxNotifyDrop, 1) } } } // TX path (TX level). if mis&rp.UART0_UARTMIS_TXMIS != 0 { + uart.inc(&uart.stats.txIRQ, 1) // Move bytes from SW buffer to HW FIFO. for !uart.Bus.UARTFR.HasBits(rp.UART0_UARTFR_TXFF) { @@ -360,12 +461,14 @@ func (uart *UART) handleInterrupt(interrupt.Interrupt) { break } uart.Bus.UARTDR.Set(uint32(b)) + uart.inc(&uart.stats.txHWBytes, 1) } // Coalesce a Writable notification (space/progress). select { case uart.txNotify <- struct{}{}: default: + uart.inc(&uart.stats.txNotifyDrop, 1) } // If SW buffer empty, manage the tail. @@ -384,22 +487,3 @@ func (uart *UART) handleInterrupt(interrupt.Interrupt) { uart.Bus.UARTICR.Set(rp.UART0_UARTICR_TXIC) } } - -func (uart *UART) noteRXErrors(errs uint32) { - if errs&rp.UART0_UARTDR_OE != 0 { - incrementRegister32(&uart.rxOverruns) - } - if errs&rp.UART0_UARTDR_BE != 0 { - incrementRegister32(&uart.rxBreaks) - } - if errs&rp.UART0_UARTDR_PE != 0 { - incrementRegister32(&uart.rxParity) - } - if errs&rp.UART0_UARTDR_FE != 0 { - incrementRegister32(&uart.rxFraming) - } -} - -func incrementRegister32(r *volatile.Register32) { - r.Set(r.Get() + 1) -} From dec3995e7a2f764bc7913ecd69050971586a6888 Mon Sep 17 00:00:00 2001 From: Rich Thanki Date: Mon, 15 Jun 2026 11:08:19 +0100 Subject: [PATCH 11/17] restores correct wiring --- docs/test-plan.md | 18 +- .../provider/setups/pico_bb_proto_1.go | 7 +- .../internal/provider/setups/pico_rich_dev.go | 7 +- services/reactor/fabric_link.go | 13 +- services/reactor/qa_reactor.go | 159 +---------------- services/reactor/reactor.go | 166 +++--------------- 6 files changed, 53 insertions(+), 317 deletions(-) diff --git a/docs/test-plan.md b/docs/test-plan.md index ea2e24c..54c497e 100644 --- a/docs/test-plan.md +++ b/docs/test-plan.md @@ -32,7 +32,7 @@ Use **6 KB** for real Big Box transfer, staging and commit tests. * TinyGo scheduler: `tasks`. * USB monitor is connected to the Pico 2 for MCU logs. * CM5/Lua side is the real Fabric peer and update sender. -* MCU Fabric link is on `uart1`. +* MCU Fabric/message-bus link is on `uart0`; human-readable diagnostics are on `uart1`. * Fabric protocol is JSONL over UART. * CM5 should see the MCU as node `mcu`. * The updater target is `updater/main`. @@ -83,8 +83,8 @@ Expected MCU log: ```text [updater] policy safe-defaults:apply-disabled -[uart1] fabric session opening node=mcu peer=bigbox-cm5 link=mcu-uart0 transfer=stage-disabled -[uart1] fabric session opened node=mcu peer=bigbox-cm5 link=mcu-uart0 transfer=stage-disabled +[uart0] fabric session opening node=mcu peer=bigbox-cm5 link=mcu-uart0 transfer=stage-disabled +[uart0] fabric session opened node=mcu peer=bigbox-cm5 link=mcu-uart0 transfer=stage-disabled ``` Expected behaviour: @@ -109,8 +109,8 @@ Expected MCU log: ```text [updater] policy safe-defaults:apply-disabled -[uart1] fabric session opening node=mcu peer=bigbox-cm5 link=mcu-uart0 transfer=stage-controller:hwtest -[uart1] fabric session opened node=mcu peer=bigbox-cm5 link=mcu-uart0 transfer=stage-controller:hwtest +[uart0] fabric session opening node=mcu peer=bigbox-cm5 link=mcu-uart0 transfer=stage-controller:hwtest +[uart0] fabric session opened node=mcu peer=bigbox-cm5 link=mcu-uart0 transfer=stage-controller:hwtest ``` Expected CM5/Lua behaviour: @@ -173,8 +173,8 @@ Expected MCU log: ```text [updater] policy safe-defaults:apply-disabled -[uart1] fabric session opening node=mcu peer=bigbox-cm5 link=mcu-uart0 transfer=stage-controller:flash-stage -[uart1] fabric session opened node=mcu peer=bigbox-cm5 link=mcu-uart0 transfer=stage-controller:flash-stage +[uart0] fabric session opening node=mcu peer=bigbox-cm5 link=mcu-uart0 transfer=stage-controller:flash-stage +[uart0] fabric session opened node=mcu peer=bigbox-cm5 link=mcu-uart0 transfer=stage-controller:flash-stage ``` Expected CM5/Lua behaviour: @@ -213,8 +213,8 @@ Expected MCU log: ```text [updater] policy production-applier:commit-reboots -[uart1] fabric session opening node=mcu peer=bigbox-cm5 link=mcu-uart0 transfer=stage-controller:flash-stage -[uart1] fabric session opened node=mcu peer=bigbox-cm5 link=mcu-uart0 transfer=stage-controller:flash-stage +[uart0] fabric session opening node=mcu peer=bigbox-cm5 link=mcu-uart0 transfer=stage-controller:flash-stage +[uart0] fabric session opened node=mcu peer=bigbox-cm5 link=mcu-uart0 transfer=stage-controller:flash-stage ``` Expected CM5/Lua behaviour: diff --git a/services/hal/internal/provider/setups/pico_bb_proto_1.go b/services/hal/internal/provider/setups/pico_bb_proto_1.go index df47893..a619110 100644 --- a/services/hal/internal/provider/setups/pico_bb_proto_1.go +++ b/services/hal/internal/provider/setups/pico_bb_proto_1.go @@ -18,7 +18,8 @@ var SelectedPlan = ResourcePlan{ {ID: "i2c1", SDA: 18, SCL: 19, Hz: 400_000}, }, UART: []UARTPlan{ - // RP2040 default pins for Pico + // Hardware v1 serial roles: uart0 is the CM5 Fabric/message bus; + // uart1 is the human-readable diagnostics mirror. {ID: "uart0", TX: 0, RX: 1, Baud: 115_200}, {ID: "uart1", TX: 4, RX: 5, Baud: 115_200}, }, @@ -47,7 +48,7 @@ var SelectedSetup = types.HALConfig{ {ID: "die_temp", Type: "rp2_temp", Params: rp2_temp.Params{Domain: "env", Name: "die"}}, - // Raw serial device bound to uart0 (public address hal/cap/io/serial/uart0/…) + // Raw serial device bound to uart0: CM5 Fabric/message bus. {ID: "uart0_raw", Type: "serial_raw", Params: serialraw.Params{ Bus: "uart0", Domain: "io", @@ -57,7 +58,7 @@ var SelectedSetup = types.HALConfig{ TXSize: rawSerialSessionSize, }}, - // Raw serial device bound to uart1 (public address hal/cap/io/serial/uart1/…) + // Raw serial device bound to uart1: human-readable diagnostics. {ID: "uart1_raw", Type: "serial_raw", Params: serialraw.Params{ Bus: "uart1", Domain: "io", diff --git a/services/hal/internal/provider/setups/pico_rich_dev.go b/services/hal/internal/provider/setups/pico_rich_dev.go index ddbefb2..e7bf11a 100644 --- a/services/hal/internal/provider/setups/pico_rich_dev.go +++ b/services/hal/internal/provider/setups/pico_rich_dev.go @@ -17,7 +17,8 @@ var SelectedPlan = ResourcePlan{ {ID: "i2c1", SDA: 18, SCL: 19, Hz: 400_000}, }, UART: []UARTPlan{ - // RP2040 default pins for Pico + // Hardware v1 serial roles: uart0 is the CM5 Fabric/message bus; + // uart1 is the human-readable diagnostics mirror. {ID: "uart0", TX: 0, RX: 1, Baud: 115_200}, {ID: "uart1", TX: 4, RX: 5, Baud: 115_200}, }, @@ -41,7 +42,7 @@ var SelectedSetup = types.HALConfig{ {ID: "die_temp", Type: "rp2_temp", Params: rp2_temp.Params{Domain: "env", Name: "die"}}, - // Raw serial device bound to uart0 (public address hal/cap/io/serial/uart0/…) + // Raw serial device bound to uart0: CM5 Fabric/message bus. {ID: "uart0_raw", Type: "serial_raw", Params: serialraw.Params{ Bus: "uart0", Domain: "io", @@ -51,7 +52,7 @@ var SelectedSetup = types.HALConfig{ TXSize: rawSerialSessionSize, }}, - // Raw serial device bound to uart1 (public address hal/cap/io/serial/uart1/…) + // Raw serial device bound to uart1: human-readable diagnostics. {ID: "uart1_raw", Type: "serial_raw", Params: serialraw.Params{ Bus: "uart1", Domain: "io", diff --git a/services/reactor/fabric_link.go b/services/reactor/fabric_link.go index 1a0d25f..856072f 100644 --- a/services/reactor/fabric_link.go +++ b/services/reactor/fabric_link.go @@ -12,7 +12,8 @@ import ( ) const ( - fabricUART = "uart1" + fabricUART = "uart0" + fabricLogPrefix = "[" + fabricUART + "] " fabricStopWaitTimeout = 500 * time.Millisecond ) @@ -47,7 +48,7 @@ func (r *Reactor) startPassiveFabric(ctx context.Context, ev types.SerialSession rx := shmring.Get(shmring.Handle(ev.RXHandle)) tx := shmring.Get(shmring.Handle(ev.TXHandle)) if rx == nil || tx == nil { - log.Println("[uart1] fabric session missing rings") + log.Println(fabricLogPrefix + "fabric session missing rings") return } @@ -55,7 +56,7 @@ func (r *Reactor) startPassiveFabric(ctx context.Context, ev types.SerialSession fabricConn := r.uiConn.NewChildConnection("fabric") if fabricConn == nil { _ = tr.Close() - log.Println("[uart1] fabric session missing bus") + log.Println(fabricLogPrefix + "fabric session missing bus") return } @@ -68,7 +69,7 @@ func (r *Reactor) startPassiveFabric(ctx context.Context, ev types.SerialSession r.fabricDone = done r.fabricSessionOpen = true - log.Println("[uart1] fabric session opening node=mcu peer=bigbox-cm5 link=mcu-uart0 transfer=", transferMode) + log.Println(fabricLogPrefix+"fabric session opening node=mcu peer=bigbox-cm5 link=mcu-uart0 transfer=", transferMode) go func() { defer close(done) defer tr.Close() @@ -78,7 +79,7 @@ func (r *Reactor) startPassiveFabric(ctx context.Context, ev types.SerialSession // to the updater-owned stage controller. fabric.RunWithOptions(fabricCtx, tr, fabricConn, "mcu", "bigbox-cm5", fabric.DefaultLinkConfig(), fabric.RunOptions{Buffers: &fabricBuffers, StageController: stageController}) }() - log.Println("[uart1] fabric session opened node=mcu peer=bigbox-cm5 link=mcu-uart0 transfer=", transferMode) + log.Println(fabricLogPrefix+"fabric session opened node=mcu peer=bigbox-cm5 link=mcu-uart0 transfer=", transferMode) } func (r *Reactor) stopFabricLink() { @@ -91,6 +92,6 @@ func (r *Reactor) stopFabricLink() { r.fabricDone = nil r.fabricSessionOpen = false if !waitFabricDone(done, fabricStopWaitTimeout) { - log.Println("[uart1] fabric session stop timed out") + log.Println(fabricLogPrefix + "fabric session stop timed out") } } diff --git a/services/reactor/qa_reactor.go b/services/reactor/qa_reactor.go index 813779f..91d9686 100644 --- a/services/reactor/qa_reactor.go +++ b/services/reactor/qa_reactor.go @@ -131,8 +131,7 @@ type Reactor struct { uiConn *bus.Connection // UART - jsonOut *shmring.Ring // telemetry (JSON UART TX) - // Logger UART1 already handled by global logger (see SetUART1) + // Human-readable logs are mirrored to uart1. Legacy JSON telemetry is not emitted. // inputs (latest) vin_mV, vbat_mV int32 @@ -162,9 +161,6 @@ type Reactor struct { // misc now time.Time - - // telemetry drop counters (bytes) - droppedUART0Bytes int } func NewReactor(uiConn *bus.Connection) *Reactor { @@ -320,66 +316,13 @@ func (r *Reactor) stepLED() { } } -// ---- public input updaters (emit telemetry) ---- +// ---- public input updaters ---- func (r *Reactor) OnCharger(v types.ChargerValue) { r.vin_mV = v.VIN_mV r.iin_mA = v.IIn_mA r.tsVIN = r.now - // JSON: {"power/charger/internal/vin":..,"vsys":..,"iin":..} - if r.jsonOut != nil { - var w utilities.JSONWriter - w.Write = r.jsonWrite - w.Begin() - w.KvInt("power/charger/internal/vin", int(v.VIN_mV)) - w.KvInt("power/charger/internal/vsys", int(v.VSYS_mV)) - w.KvInt("power/charger/internal/iin", int(v.IIn_mA)) - // Full bitfield maps (0/1) for LOCF pipelines - { - it := types.NewBitIter(types.SystemStatus(v.Sys), types.SystemStatusTable[:]) - for { - bitName, set, ok := it.NextAny() - if !ok { - break - } - if set { - w.KvInt("power/charger/internal/system/"+bitName, 1) - } else { - w.KvInt("power/charger/internal/system/"+bitName, 0) - } - } - } - { - it := types.NewBitIter(types.ChargeStatusBits(v.Status), types.ChargeStatusTable[:]) - for { - bitName, set, ok := it.NextAny() - if !ok { - break - } - if set { - w.KvInt("power/charger/internal/status/"+bitName, 1) - } else { - w.KvInt("power/charger/internal/status/"+bitName, 0) - } - } - } - { - it := types.NewBitIter(types.ChargerStateBits(v.State), types.ChargerStateTable[:]) - for { - bitName, set, ok := it.NextAny() - if !ok { - break - } - if set { - w.KvInt("power/charger/internal/state/"+bitName, 1) - } else { - w.KvInt("power/charger/internal/state/"+bitName, 0) - } - } - } - w.End() - } } func (r *Reactor) OnBattery(v types.BatteryValue) { @@ -387,30 +330,13 @@ func (r *Reactor) OnBattery(v types.BatteryValue) { r.ibat_mA = v.IBatMilliA r.tsVBAT = r.now - // JSON: {"power/battery/internal/vbat":..,"ibat":..} - if r.jsonOut != nil { - var w utilities.JSONWriter - w.Write = r.jsonWrite - w.Begin() - w.KvInt("power/battery/internal/vbat", int(v.PackMilliV)) - w.KvInt("power/battery/internal/ibat", int(v.IBatMilliA)) - w.KvInt("power/battery/internal/bsr", int(v.BSR_uOhmPerCell)) - w.End() - } } func (r *Reactor) OnTempDeciC(label string, deci int, jsonKey string) { log.Deci(label, deci) - if r.jsonOut != nil { - var w utilities.JSONWriter - w.Write = r.jsonWrite - w.Begin() - w.KvInt(jsonKey, deci) - w.End() - } } -// ---- memory snapshot telemetry (every ~2 s in main loop) ---- +// ---- memory snapshot (every ~2 s in main loop) ---- func (r *Reactor) emitMemSnapshot() { var ms runtime.MemStats @@ -424,14 +350,6 @@ func (r *Reactor) emitMemSnapshot() { "mallocs:", int(ms.Mallocs), " ", "frees:", int(ms.Frees), ) - // JSON (minimal to keep overhead low) - if r.jsonOut != nil { - var w utilities.JSONWriter - w.Write = r.jsonWrite - w.Begin() - w.KvInt("sys/mem/alloc", int(ms.Alloc)) - w.End() - } } func (r *Reactor) Run(ctx context.Context) { @@ -444,22 +362,16 @@ func (r *Reactor) Run(ctx context.Context) { stSub := r.uiConn.Subscribe(stTopic) evSub := r.uiConn.Subscribe(evTopic) - // UART sessions (TX only needed for our use) - const ( - uartTele = "uart0" // telemetry JSON - uartLog = "uart1" // log mirror - ) - subSessOpenTele := r.uiConn.Subscribe(tSessOpened(uartTele)) + // UART session for human-readable diagnostics. + const uartLog = "uart1" subSessOpenLog := r.uiConn.Subscribe(tSessOpened(uartLog)) - subSessClosedTele := r.uiConn.Subscribe(tSessClosed(uartTele)) subSessClosedLog := r.uiConn.Subscribe(tSessClosed(uartLog)) - // Kick open requests (fire-and-forget; events carry handles) - r.uiConn.Publish(r.uiConn.NewMessage(tSessOpen(uartTele), nil, false)) + // Kick open request (fire-and-forget; events carry handles). r.uiConn.Publish(r.uiConn.NewMessage(tSessOpen(uartLog), nil, false)) - // Retry back-off guards - var retryTeleAt, retryLogAt time.Time + // Retry back-off guard. + var retryLogAt time.Time // Supervisory ticker ticker := time.NewTicker(TICK) @@ -470,24 +382,11 @@ func (r *Reactor) Run(ctx context.Context) { for { select { // ---- UART session opened/closed ---- - case m := <-subSessOpenTele.Channel(): - if ev, ok := m.Payload.(types.SerialSessionOpened); ok { - r.jsonOut = shmring.Get(shmring.Handle(ev.TXHandle)) - log.Println("[uart0] telemetry session opened") - } case m := <-subSessOpenLog.Channel(): if ev, ok := m.Payload.(types.SerialSessionOpened); ok { log.SetUART1(shmring.Get(shmring.Handle(ev.TXHandle))) log.Println("[uart1] log session opened") } - case <-subSessClosedTele.Channel(): - r.jsonOut = nil - log.Println("[uart0] telemetry session closed") - // Auto-reopen with back-off - if time.Now().After(retryTeleAt) { - r.uiConn.Publish(r.uiConn.NewMessage(tSessOpen(uartTele), nil, false)) - retryTeleAt = time.Now().Add(2 * time.Second) - } case <-subSessClosedLog.Channel(): log.SetUART1(nil) log.Println("[uart1] log session closed") @@ -512,14 +411,6 @@ func (r *Reactor) Run(ctx context.Context) { case m := <-humidSub.Channel(): if v, ok := m.Payload.(types.HumidityValue); ok { log.Hundredths("[value] env/humidity/core %RH=", int(v.RHx100)) - // JSON - if r.jsonOut != nil { - var w utilities.JSONWriter - w.Write = r.jsonWrite - w.Begin() - w.KvInt("env/humidity/core", int(v.RHx100)) - w.End() - } } // ---- Die Temp Backup ---- @@ -554,20 +445,6 @@ func (r *Reactor) Run(ctx context.Context) { case m := <-evSub.Channel(): printCapEvent(m) - // JSON: {"///event":""} - if r.jsonOut != nil { - dom, _ := m.Topic.At(2).(string) - kind, _ := m.Topic.At(3).(string) - name, _ := m.Topic.At(4).(string) - tag, _ := m.Topic.At(6).(string) - if dom != "" && kind != "" && name != "" && tag != "" { - var w utilities.JSONWriter - w.Write = r.jsonWrite - w.Begin() - w.KvStr(dom+"/"+kind+"/"+name+"/event", tag) - w.End() - } - } // ---- Supervisory tick ---- case <-ticker.C: @@ -590,26 +467,6 @@ func (r *Reactor) Run(ctx context.Context) { } } -// ----------------------------------------------------------------------------- -// Centralised UART write helpers (handle partial writes) -// ----------------------------------------------------------------------------- - -// uart0 (telemetry JSON) — returns bytes written; tracks dropped bytes on partial writes. -func (r *Reactor) jsonWrite(b []byte) int { - if r == nil || r.jsonOut == nil || len(b) == 0 { - return 0 - } - n := r.jsonOut.TryWriteFrom(b) - if n < len(b) { - r.droppedUART0Bytes += (len(b) - n) - // Rate-limited note - if r.droppedUART0Bytes == (len(b)-n) || (r.droppedUART0Bytes%1024) == 0 { - log.Println("[uart0] dropped bytes =", r.droppedUART0Bytes) - } - } - return n -} - // ----------------------------------------------------------------------------- // Printing helpers (via Logger) // ----------------------------------------------------------------------------- diff --git a/services/reactor/reactor.go b/services/reactor/reactor.go index 7d9bdb1..5eadac7 100644 --- a/services/reactor/reactor.go +++ b/services/reactor/reactor.go @@ -139,8 +139,7 @@ type Reactor struct { uiConn *bus.Connection // UART - jsonOut *shmring.Ring // telemetry (JSON UART TX) - // Logger UART1 already handled by global logger (see SetUART1) + // Fabric uses uart0; human-readable logs are mirrored to uart1. // inputs (latest) vin_mV, vbat_mV int32 @@ -171,9 +170,6 @@ type Reactor struct { // misc now time.Time - // telemetry drop counters (bytes) - droppedUART0Bytes int - // supervised children. The Reactor owns only lifecycle; child // services own their own event loops and models. children childSupervisor @@ -387,66 +383,13 @@ func (r *Reactor) stepLED() { } } -// ---- public input updaters (emit telemetry) ---- +// ---- public input updaters ---- func (r *Reactor) OnCharger(v types.ChargerValue) { r.vin_mV = v.VIN_mV r.iin_mA = v.IIn_mA r.tsVIN = r.now - // JSON: {"power/charger/internal/vin":..,"vsys":..,"iin":..} - if r.jsonOut != nil { - var w utilities.JSONWriter - w.Write = r.jsonWrite - w.Begin() - w.KvInt("power/charger/internal/vin", int(v.VIN_mV)) - w.KvInt("power/charger/internal/vsys", int(v.VSYS_mV)) - w.KvInt("power/charger/internal/iin", int(v.IIn_mA)) - // Full bitfield maps (0/1) for LOCF pipelines - { - it := types.NewBitIter(types.SystemStatus(v.Sys), types.SystemStatusTable[:]) - for { - bitName, set, ok := it.NextAny() - if !ok { - break - } - if set { - w.KvInt("power/charger/internal/system/"+bitName, 1) - } else { - w.KvInt("power/charger/internal/system/"+bitName, 0) - } - } - } - { - it := types.NewBitIter(types.ChargeStatusBits(v.Status), types.ChargeStatusTable[:]) - for { - bitName, set, ok := it.NextAny() - if !ok { - break - } - if set { - w.KvInt("power/charger/internal/status/"+bitName, 1) - } else { - w.KvInt("power/charger/internal/status/"+bitName, 0) - } - } - } - { - it := types.NewBitIter(types.ChargerStateBits(v.State), types.ChargerStateTable[:]) - for { - bitName, set, ok := it.NextAny() - if !ok { - break - } - if set { - w.KvInt("power/charger/internal/state/"+bitName, 1) - } else { - w.KvInt("power/charger/internal/state/"+bitName, 0) - } - } - } - w.End() - } } func (r *Reactor) OnBattery(v types.BatteryValue) { @@ -454,30 +397,13 @@ func (r *Reactor) OnBattery(v types.BatteryValue) { r.ibat_mA = v.IBatMilliA r.tsVBAT = r.now - // JSON: {"power/battery/internal/vbat":..,"ibat":..} - if r.jsonOut != nil { - var w utilities.JSONWriter - w.Write = r.jsonWrite - w.Begin() - w.KvInt("power/battery/internal/vbat", int(v.PackMilliV)) - w.KvInt("power/battery/internal/ibat", int(v.IBatMilliA)) - w.KvInt("power/battery/internal/bsr", int(v.BSR_uOhmPerCell)) - w.End() - } } func (r *Reactor) OnTempDeciC(label string, deci int, jsonKey string) { log.Deci(label, deci) - if r.jsonOut != nil { - var w utilities.JSONWriter - w.Write = r.jsonWrite - w.Begin() - w.KvInt(jsonKey, deci) - w.End() - } } -// ---- memory snapshot telemetry (every ~2 s in main loop) ---- +// ---- memory snapshot (every ~2 s in main loop) ---- func (r *Reactor) emitMemSnapshot() { var ms runtime.MemStats @@ -491,14 +417,6 @@ func (r *Reactor) emitMemSnapshot() { "mallocs:", int(ms.Mallocs), " ", "frees:", int(ms.Frees), ) - // JSON (minimal to keep overhead low) - if r.jsonOut != nil { - var w utilities.JSONWriter - w.Write = r.jsonWrite - w.Begin() - w.KvInt("sys/mem/alloc", int(ms.Alloc)) - w.End() - } } func (r *Reactor) Run(ctx context.Context) { @@ -515,12 +433,12 @@ func (r *Reactor) Run(ctx context.Context) { stSub := r.uiConn.Subscribe(stTopic) evSub := r.uiConn.Subscribe(evTopic) - // UART sessions. uart0 remains the original local JSON telemetry stream; - // uart1 is now reserved for the CM5 Fabric link. The logger still writes to - // the USB monitor, but no longer opens a competing uart1 log mirror. - const uartTele = "uart0" - subSessOpenTele := r.uiConn.Subscribe(tSessOpened(uartTele)) - subSessClosedTele := r.uiConn.Subscribe(tSessClosed(uartTele)) + // UART sessions. uart0 is the CM5 Fabric/message-bus link; uart1 is + // reserved for human-readable diagnostics. Legacy JSON telemetry is not + // emitted on either UART. + const uartLog = "uart1" + subSessOpenLog := r.uiConn.Subscribe(tSessOpened(uartLog)) + subSessClosedLog := r.uiConn.Subscribe(tSessClosed(uartLog)) var subSessOpenFabric *bus.Subscription var subSessClosedFabric *bus.Subscription if useHardwareFabricUART() { @@ -529,13 +447,13 @@ func (r *Reactor) Run(ctx context.Context) { } // Kick open requests (fire-and-forget; events carry handles). - r.uiConn.Publish(r.uiConn.NewMessage(tSessOpen(uartTele), nil, false)) + r.uiConn.Publish(r.uiConn.NewMessage(tSessOpen(uartLog), nil, false)) if useHardwareFabricUART() { r.uiConn.Publish(r.uiConn.NewMessage(tSessOpen(fabricUART), nil, false)) } // Retry back-off guards. - var retryTeleAt, retryFabricAt time.Time + var retryLogAt, retryFabricAt time.Time // Supervisory ticker ticker := time.NewTicker(TICK) @@ -546,26 +464,26 @@ func (r *Reactor) Run(ctx context.Context) { for { select { // ---- UART session opened/closed ---- - case m := <-subSessOpenTele.Channel(): + case m := <-subSessOpenLog.Channel(): if ev, ok := m.Payload.(types.SerialSessionOpened); ok { - r.jsonOut = shmring.Get(shmring.Handle(ev.TXHandle)) - log.Println("[uart0] telemetry session opened") + log.SetUART1(shmring.Get(shmring.Handle(ev.TXHandle))) + log.Println("[uart1] log session opened") } case m := <-subscriptionChannel(subSessOpenFabric): if ev, ok := m.Payload.(types.SerialSessionOpened); ok { r.startPassiveFabric(ctx, ev) } - case <-subSessClosedTele.Channel(): - r.jsonOut = nil - log.Println("[uart0] telemetry session closed") + case <-subSessClosedLog.Channel(): + log.SetUART1(nil) + log.Println("[uart1] log session closed") // Auto-reopen with back-off - if time.Now().After(retryTeleAt) { - r.uiConn.Publish(r.uiConn.NewMessage(tSessOpen(uartTele), nil, false)) - retryTeleAt = time.Now().Add(2 * time.Second) + if time.Now().After(retryLogAt) { + r.uiConn.Publish(r.uiConn.NewMessage(tSessOpen(uartLog), nil, false)) + retryLogAt = time.Now().Add(2 * time.Second) } case <-subscriptionChannel(subSessClosedFabric): r.stopFabricLink() - log.Println("[uart1] fabric session closed") + log.Println(fabricLogPrefix + "fabric session closed") // Auto-reopen with back-off if time.Now().After(retryFabricAt) { r.uiConn.Publish(r.uiConn.NewMessage(tSessOpen(fabricUART), nil, false)) @@ -587,14 +505,6 @@ func (r *Reactor) Run(ctx context.Context) { case m := <-humidSub.Channel(): if v, ok := m.Payload.(types.HumidityValue); ok { log.Hundredths("[value] env/humidity/core %RH=", int(v.RHx100)) - // JSON - if r.jsonOut != nil { - var w utilities.JSONWriter - w.Write = r.jsonWrite - w.Begin() - w.KvInt("env/humidity/core", int(v.RHx100)) - w.End() - } } // ---- Die Temp Backup ---- @@ -629,20 +539,6 @@ func (r *Reactor) Run(ctx context.Context) { case m := <-evSub.Channel(): printCapEvent(m) - // JSON: {"///event":""} - if r.jsonOut != nil { - dom, _ := m.Topic.At(2).(string) - kind, _ := m.Topic.At(3).(string) - name, _ := m.Topic.At(4).(string) - tag, _ := m.Topic.At(6).(string) - if dom != "" && kind != "" && name != "" && tag != "" { - var w utilities.JSONWriter - w.Write = r.jsonWrite - w.Begin() - w.KvStr(dom+"/"+kind+"/"+name+"/event", tag) - w.End() - } - } // ---- Child service lifecycle ---- case ev := <-r.children.Done(): @@ -672,26 +568,6 @@ func (r *Reactor) Run(ctx context.Context) { } } -// ----------------------------------------------------------------------------- -// Centralised UART write helpers (handle partial writes) -// ----------------------------------------------------------------------------- - -// uart0 (telemetry JSON) — returns bytes written; tracks dropped bytes on partial writes. -func (r *Reactor) jsonWrite(b []byte) int { - if r == nil || r.jsonOut == nil || len(b) == 0 { - return 0 - } - n := r.jsonOut.TryWriteFrom(b) - if n < len(b) { - r.droppedUART0Bytes += (len(b) - n) - // Rate-limited note - if r.droppedUART0Bytes == (len(b)-n) || (r.droppedUART0Bytes%1024) == 0 { - log.Println("[uart0] dropped bytes =", r.droppedUART0Bytes) - } - } - return n -} - // ----------------------------------------------------------------------------- // Printing helpers (via Logger) // ----------------------------------------------------------------------------- From 25098b7dc1c66b9ec5ce2c43b5bb4844f2e2b3d1 Mon Sep 17 00:00:00 2001 From: Rich Thanki Date: Mon, 15 Jun 2026 13:46:18 +0100 Subject: [PATCH 12/17] parser escaping fix --- docs/gate2-hello-diagnostic.md | 81 ++++++++++++++++++++++++++++++++++ docs/uartx-probe.md | 2 +- services/fabric/fabric_test.go | 46 +++++++++++++++++++ services/fabric/protocol.go | 53 +++++++++++++++++++++- 4 files changed, 179 insertions(+), 3 deletions(-) create mode 100644 docs/gate2-hello-diagnostic.md diff --git a/docs/gate2-hello-diagnostic.md b/docs/gate2-hello-diagnostic.md new file mode 100644 index 0000000..6d52c02 --- /dev/null +++ b/docs/gate2-hello-diagnostic.md @@ -0,0 +1,81 @@ +# Gate 2 hello/hello_ack diagnostic build + +Use this build only when the CM5 can open `uart0` and Fabric is running, but the +CM5 link remains in `state=hello` and does not observe the MCU peer. + +This is a safe Gate 2 diagnostic build. It uses the hwtest updater backend: + +```text +pico_bb_proto_1 fabric_uart_hwtest fabric_trace uartx_probe +``` + +It must not enable production flash staging or apply/reboot. Do not add +`fabric_stage_enabled` or `fabric_apply_enabled` to this diagnostic run. + +## Flash command + +```sh +tinygo flash -stack-size=8KB -monitor -scheduler tasks \ + -target=pico2 \ + -tags "pico_bb_proto_1 fabric_uart_hwtest fabric_trace uartx_probe" \ + main.go +``` + +The same command is available as: + +```sh +./scripts/flash-mcu-gate2-hello-diag.sh +``` + +## Expected startup markers + +The correct hardware role image should log: + +```text +[uart1] log session opened +[uart0] fabric session opening node=mcu peer=bigbox-cm5 link=mcu-uart0 transfer=stage-controller:hwtest +[uart0] fabric session opened node=mcu peer=bigbox-cm5 link=mcu-uart0 transfer=stage-controller:hwtest +``` + +## What to look for + +When the CM5 current session reports a local SID, for example: + +```text +local_sid=a53d3e09-... +``` + +look in the MCU diagnostic output for the same SID. + +Good receive path: + +```text +[fabric-trace] rx len ... line {"type":"hello",..."sid":"a53d3e09-...","node":"bigbox-cm5"...} +[fabric] sid mcu-sid-... rx_frame type hello ... +``` + +Good reply path: + +```text +[fabric] sid mcu-sid-... tx_frame lane control type hello_ack ... +[fabric-trace] tx len ... line {"type":"hello_ack",..."node":"mcu"...} +[uartx-probe] uart0 reason tx ... tx_acc ... tx_hw ... sess_tx_avail ... +``` + +## Interpretation + +If there is no `rx` line for the CM5's current SID, the current CM5 hello is not +reaching the MCU. Check single process ownership of `/dev/ttyAMA0`, wiring, +shared ground, and that the CM5 TX pin reaches MCU UART0 RX GP1. + +If the MCU logs `rx hello` and `tx hello_ack`, but `uartx-probe` does not show +TX progress on `uart0`, the fault is between Fabric and the Go HAL serial TX +path. + +If the MCU logs `rx hello`, `tx hello_ack`, and UARTX TX progress, but the CM5 +still stays in `hello`, the fault is on the reverse physical path: +MCU UART0 TX GP0 to CM5 RX, or in the CM5 tty receive configuration. + +If the MCU logs a `peer_sid` that does not match the CM5's current `local_sid`, +repeat the run after stopping all old CM5 processes, confirming `/dev/ttyAMA0` +has one owner, power-cycling the MCU, and starting the CM5 process again. diff --git a/docs/uartx-probe.md b/docs/uartx-probe.md index 6abc756..85f0f25 100644 --- a/docs/uartx-probe.md +++ b/docs/uartx-probe.md @@ -17,7 +17,7 @@ tinygo flash -stack-size=8KB -monitor -scheduler tasks \ The probe prints compact lines from the HAL `serial_raw` session worker: ```text -[uartx-probe] uart1 reason periodic rx_hw ... rx_drop ... rx_oe ... rx_fe ... sess_rx_avail ... +[uartx-probe] uart0 reason periodic rx_hw ... rx_drop ... rx_oe ... rx_fe ... sess_rx_avail ... ``` The most useful fields are: diff --git a/services/fabric/fabric_test.go b/services/fabric/fabric_test.go index cd0f50e..cdd4996 100644 --- a/services/fabric/fabric_test.go +++ b/services/fabric/fabric_test.go @@ -153,6 +153,52 @@ func TestCodecAllTypes(t *testing.T) { } } +func TestFastStringDecoderAcceptsEscapedSlashProtocol(t *testing.T) { + line := []byte(`{"node":"bigbox-cm5","type":"hello","identity":{"id":"bigbox-cm5","role":"controller"},"proto":"fabric-jsonl\/1","sid":"9063d561-97b8-45df-8109-43a947708600"}`) + + if got := protoType(line); got != msgHello { + t.Fatalf("protoType = %q, want %q", got, msgHello) + } + if got := protoTopString(line, "proto"); got != protocolName { + t.Fatalf("proto = %q, want %q", got, protocolName) + } + msg, ok := decodeHelloFast(line) + if !ok { + t.Fatalf("decodeHelloFast rejected escaped-slash proto") + } + if msg.Proto != protocolName || msg.Node != "bigbox-cm5" || msg.SID != "9063d561-97b8-45df-8109-43a947708600" { + t.Fatalf("bad hello decode: %+v", msg) + } +} + +func TestFastStringArrayDecoderAcceptsEscapedValues(t *testing.T) { + line := []byte(`{"type":"call","id":"c1","topic":["cap","update-manager","main","rpc","commit-job\/test"],"payload":{}}`) + + msg, ok := decodeCallFast(line) + if !ok { + t.Fatalf("decodeCallFast rejected escaped topic") + } + if len(msg.Topic) != 5 || msg.Topic[4] != "commit-job/test" { + t.Fatalf("topic = %#v", msg.Topic) + } +} + +func TestHandshakeAcceptsEscapedSlashProtocolFromCM5(t *testing.T) { + mcu, cm5 := pipePair() + b := newBus() + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + go Run(ctx, mcu, b.NewConnection("fabric"), "mcu", "bigbox-cm5", DefaultLinkConfig()) + + if err := cm5.WriteLine([]byte(`{"node":"bigbox-cm5","type":"hello","identity":{"id":"bigbox-cm5","role":"controller"},"proto":"fabric-jsonl\/1","sid":"s-escaped-proto"}`)); err != nil { + t.Fatalf("WriteLine: %v", err) + } + ack := readMsg[protoHelloAck](t, cm5) + if ack.Node != "mcu" || ack.SID == "" || ack.Proto != protocolName { + t.Fatalf("bad hello_ack: %+v", ack) + } +} + func TestWireTypeBadInput(t *testing.T) { for _, b := range [][]byte{[]byte("not json"), []byte(`{"no_type":true}`), nil} { if got := protoType(b); got != "" { diff --git a/services/fabric/protocol.go b/services/fabric/protocol.go index d13a17a..8773002 100644 --- a/services/fabric/protocol.go +++ b/services/fabric/protocol.go @@ -515,7 +515,11 @@ func protoTopStringArray(line []byte, field string) []string { if !ok { return nil } - out = append(out, string(line[start:end-1])) + value, ok := decodeJSONStringValue(line[start : end-1]) + if !ok { + return nil + } + out = append(out, value) i = end } } @@ -585,7 +589,11 @@ func protoTopString(line []byte, field string) string { if !ok { return "" } - return string(line[valStart : valEnd-1]) + value, ok := decodeJSONStringValue(line[valStart : valEnd-1]) + if !ok { + return "" + } + return value } i, ok = skipJSONValue(line, i) if !ok { @@ -594,6 +602,47 @@ func protoTopString(line []byte, field string) string { } } +func decodeJSONStringValue(raw []byte) (string, bool) { + for _, c := range raw { + if c == '\\' { + return decodeEscapedJSONStringValue(raw) + } + } + return string(raw), true +} + +func decodeEscapedJSONStringValue(raw []byte) (string, bool) { + out := make([]byte, 0, len(raw)) + for i := 0; i < len(raw); i++ { + c := raw[i] + if c != '\\' { + out = append(out, c) + continue + } + if i+1 >= len(raw) { + return "", false + } + i++ + switch raw[i] { + case '"', '\\', '/': + out = append(out, raw[i]) + case 'b': + out = append(out, '\b') + case 'f': + out = append(out, '\f') + case 'n': + out = append(out, '\n') + case 'r': + out = append(out, '\r') + case 't': + out = append(out, '\t') + default: + return "", false + } + } + return string(out), true +} + func jsonKeyEquals(key []byte, field string) bool { if len(key) != len(field) { return false From 12fc7ac8599cf573c85aeb4cc5f5f945e62726ed Mon Sep 17 00:00:00 2001 From: Rich Thanki Date: Mon, 15 Jun 2026 17:16:00 +0100 Subject: [PATCH 13/17] light instru --- docs/test-plan.md | 18 +- docs/uartx-probe.md | 2 +- services/fabric/fabric_test.go | 31 +--- services/fabric/transfer.go | 32 +++- .../provider/setups/pico_bb_proto_1.go | 7 +- .../internal/provider/setups/pico_rich_dev.go | 7 +- services/reactor/qa_reactor.go | 159 +++++++++++++++++- services/reactor/reactor.go | 16 +- 8 files changed, 217 insertions(+), 55 deletions(-) diff --git a/docs/test-plan.md b/docs/test-plan.md index 54c497e..ea2e24c 100644 --- a/docs/test-plan.md +++ b/docs/test-plan.md @@ -32,7 +32,7 @@ Use **6 KB** for real Big Box transfer, staging and commit tests. * TinyGo scheduler: `tasks`. * USB monitor is connected to the Pico 2 for MCU logs. * CM5/Lua side is the real Fabric peer and update sender. -* MCU Fabric/message-bus link is on `uart0`; human-readable diagnostics are on `uart1`. +* MCU Fabric link is on `uart1`. * Fabric protocol is JSONL over UART. * CM5 should see the MCU as node `mcu`. * The updater target is `updater/main`. @@ -83,8 +83,8 @@ Expected MCU log: ```text [updater] policy safe-defaults:apply-disabled -[uart0] fabric session opening node=mcu peer=bigbox-cm5 link=mcu-uart0 transfer=stage-disabled -[uart0] fabric session opened node=mcu peer=bigbox-cm5 link=mcu-uart0 transfer=stage-disabled +[uart1] fabric session opening node=mcu peer=bigbox-cm5 link=mcu-uart0 transfer=stage-disabled +[uart1] fabric session opened node=mcu peer=bigbox-cm5 link=mcu-uart0 transfer=stage-disabled ``` Expected behaviour: @@ -109,8 +109,8 @@ Expected MCU log: ```text [updater] policy safe-defaults:apply-disabled -[uart0] fabric session opening node=mcu peer=bigbox-cm5 link=mcu-uart0 transfer=stage-controller:hwtest -[uart0] fabric session opened node=mcu peer=bigbox-cm5 link=mcu-uart0 transfer=stage-controller:hwtest +[uart1] fabric session opening node=mcu peer=bigbox-cm5 link=mcu-uart0 transfer=stage-controller:hwtest +[uart1] fabric session opened node=mcu peer=bigbox-cm5 link=mcu-uart0 transfer=stage-controller:hwtest ``` Expected CM5/Lua behaviour: @@ -173,8 +173,8 @@ Expected MCU log: ```text [updater] policy safe-defaults:apply-disabled -[uart0] fabric session opening node=mcu peer=bigbox-cm5 link=mcu-uart0 transfer=stage-controller:flash-stage -[uart0] fabric session opened node=mcu peer=bigbox-cm5 link=mcu-uart0 transfer=stage-controller:flash-stage +[uart1] fabric session opening node=mcu peer=bigbox-cm5 link=mcu-uart0 transfer=stage-controller:flash-stage +[uart1] fabric session opened node=mcu peer=bigbox-cm5 link=mcu-uart0 transfer=stage-controller:flash-stage ``` Expected CM5/Lua behaviour: @@ -213,8 +213,8 @@ Expected MCU log: ```text [updater] policy production-applier:commit-reboots -[uart0] fabric session opening node=mcu peer=bigbox-cm5 link=mcu-uart0 transfer=stage-controller:flash-stage -[uart0] fabric session opened node=mcu peer=bigbox-cm5 link=mcu-uart0 transfer=stage-controller:flash-stage +[uart1] fabric session opening node=mcu peer=bigbox-cm5 link=mcu-uart0 transfer=stage-controller:flash-stage +[uart1] fabric session opened node=mcu peer=bigbox-cm5 link=mcu-uart0 transfer=stage-controller:flash-stage ``` Expected CM5/Lua behaviour: diff --git a/docs/uartx-probe.md b/docs/uartx-probe.md index 85f0f25..6abc756 100644 --- a/docs/uartx-probe.md +++ b/docs/uartx-probe.md @@ -17,7 +17,7 @@ tinygo flash -stack-size=8KB -monitor -scheduler tasks \ The probe prints compact lines from the HAL `serial_raw` session worker: ```text -[uartx-probe] uart0 reason periodic rx_hw ... rx_drop ... rx_oe ... rx_fe ... sess_rx_avail ... +[uartx-probe] uart1 reason periodic rx_hw ... rx_drop ... rx_oe ... rx_fe ... sess_rx_avail ... ``` The most useful fields are: diff --git a/services/fabric/fabric_test.go b/services/fabric/fabric_test.go index cdd4996..d914771 100644 --- a/services/fabric/fabric_test.go +++ b/services/fabric/fabric_test.go @@ -154,26 +154,21 @@ func TestCodecAllTypes(t *testing.T) { } func TestFastStringDecoderAcceptsEscapedSlashProtocol(t *testing.T) { - line := []byte(`{"node":"bigbox-cm5","type":"hello","identity":{"id":"bigbox-cm5","role":"controller"},"proto":"fabric-jsonl\/1","sid":"9063d561-97b8-45df-8109-43a947708600"}`) - + line := []byte(`{"node":"bigbox-cm5","type":"hello","identity":{"id":"bigbox-cm5","role":"controller"},"proto":"fabric-jsonl\/1","sid":"s1"}`) if got := protoType(line); got != msgHello { - t.Fatalf("protoType = %q, want %q", got, msgHello) - } - if got := protoTopString(line, "proto"); got != protocolName { - t.Fatalf("proto = %q, want %q", got, protocolName) + t.Fatalf("protoType = %q", got) } msg, ok := decodeHelloFast(line) if !ok { - t.Fatalf("decodeHelloFast rejected escaped-slash proto") + t.Fatalf("decodeHelloFast rejected escaped slash proto") } - if msg.Proto != protocolName || msg.Node != "bigbox-cm5" || msg.SID != "9063d561-97b8-45df-8109-43a947708600" { - t.Fatalf("bad hello decode: %+v", msg) + if msg.Proto != protocolName || msg.SID != "s1" || msg.Node != "bigbox-cm5" { + t.Fatalf("bad hello: %+v", msg) } } func TestFastStringArrayDecoderAcceptsEscapedValues(t *testing.T) { line := []byte(`{"type":"call","id":"c1","topic":["cap","update-manager","main","rpc","commit-job\/test"],"payload":{}}`) - msg, ok := decodeCallFast(line) if !ok { t.Fatalf("decodeCallFast rejected escaped topic") @@ -183,22 +178,6 @@ func TestFastStringArrayDecoderAcceptsEscapedValues(t *testing.T) { } } -func TestHandshakeAcceptsEscapedSlashProtocolFromCM5(t *testing.T) { - mcu, cm5 := pipePair() - b := newBus() - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - go Run(ctx, mcu, b.NewConnection("fabric"), "mcu", "bigbox-cm5", DefaultLinkConfig()) - - if err := cm5.WriteLine([]byte(`{"node":"bigbox-cm5","type":"hello","identity":{"id":"bigbox-cm5","role":"controller"},"proto":"fabric-jsonl\/1","sid":"s-escaped-proto"}`)); err != nil { - t.Fatalf("WriteLine: %v", err) - } - ack := readMsg[protoHelloAck](t, cm5) - if ack.Node != "mcu" || ack.SID == "" || ack.Proto != protocolName { - t.Fatalf("bad hello_ack: %+v", ack) - } -} - func TestWireTypeBadInput(t *testing.T) { for _, b := range [][]byte{[]byte("not json"), []byte(`{"no_type":true}`), nil} { if got := protoType(b); got != "" { diff --git a/services/fabric/transfer.go b/services/fabric/transfer.go index e8b09bb..3df3aae 100644 --- a/services/fabric/transfer.go +++ b/services/fabric/transfer.go @@ -1072,9 +1072,12 @@ func (s *session) onTransferChunk(msg *protoXferChunk) { return } got := xxhashHex(xxhash.Sum32(raw, 0)) + if xferProbeEnabled && msg.Offset == 0 { + xferProbe("chunk_digest_first", "id", id, "offset", msg.Offset, "raw_len", len(raw), "encoded_len", len(msg.Data), "got", got, "want", want, "raw_prefix16", byteHexPrefix(raw, 16), "data_prefix32", stringPrefix(msg.Data, 32)) + } if got != want { if xferProbeEnabled { - xferProbe("chunk_digest_error", "id", id, "offset", msg.Offset, "reason", "mismatch", "got", got, "want", want) + xferProbe("chunk_digest_error", "id", id, "offset", msg.Offset, "reason", "mismatch", "raw_len", len(raw), "encoded_len", len(msg.Data), "got", got, "want", want, "raw_prefix16", byteHexPrefix(raw, 16), "data_prefix32", stringPrefix(msg.Data, 32)) } s.counters.TransferDigestErrors++ if fabricXferDiagEnabled("chunk_digest_done") { @@ -1346,3 +1349,30 @@ func xxhashHex(v uint32) string { } return string(buf[:]) } + +func byteHexPrefix(b []byte, n int) string { + if n < 0 { + n = 0 + } + if n > len(b) { + n = len(b) + } + const digits = "0123456789abcdef" + out := make([]byte, n*2) + for i := 0; i < n; i++ { + v := b[i] + out[i*2] = digits[v>>4] + out[i*2+1] = digits[v&0xf] + } + return string(out) +} + +func stringPrefix(s string, n int) string { + if n < 0 { + n = 0 + } + if n > len(s) { + n = len(s) + } + return s[:n] +} diff --git a/services/hal/internal/provider/setups/pico_bb_proto_1.go b/services/hal/internal/provider/setups/pico_bb_proto_1.go index a619110..df47893 100644 --- a/services/hal/internal/provider/setups/pico_bb_proto_1.go +++ b/services/hal/internal/provider/setups/pico_bb_proto_1.go @@ -18,8 +18,7 @@ var SelectedPlan = ResourcePlan{ {ID: "i2c1", SDA: 18, SCL: 19, Hz: 400_000}, }, UART: []UARTPlan{ - // Hardware v1 serial roles: uart0 is the CM5 Fabric/message bus; - // uart1 is the human-readable diagnostics mirror. + // RP2040 default pins for Pico {ID: "uart0", TX: 0, RX: 1, Baud: 115_200}, {ID: "uart1", TX: 4, RX: 5, Baud: 115_200}, }, @@ -48,7 +47,7 @@ var SelectedSetup = types.HALConfig{ {ID: "die_temp", Type: "rp2_temp", Params: rp2_temp.Params{Domain: "env", Name: "die"}}, - // Raw serial device bound to uart0: CM5 Fabric/message bus. + // Raw serial device bound to uart0 (public address hal/cap/io/serial/uart0/…) {ID: "uart0_raw", Type: "serial_raw", Params: serialraw.Params{ Bus: "uart0", Domain: "io", @@ -58,7 +57,7 @@ var SelectedSetup = types.HALConfig{ TXSize: rawSerialSessionSize, }}, - // Raw serial device bound to uart1: human-readable diagnostics. + // Raw serial device bound to uart1 (public address hal/cap/io/serial/uart1/…) {ID: "uart1_raw", Type: "serial_raw", Params: serialraw.Params{ Bus: "uart1", Domain: "io", diff --git a/services/hal/internal/provider/setups/pico_rich_dev.go b/services/hal/internal/provider/setups/pico_rich_dev.go index e7bf11a..ddbefb2 100644 --- a/services/hal/internal/provider/setups/pico_rich_dev.go +++ b/services/hal/internal/provider/setups/pico_rich_dev.go @@ -17,8 +17,7 @@ var SelectedPlan = ResourcePlan{ {ID: "i2c1", SDA: 18, SCL: 19, Hz: 400_000}, }, UART: []UARTPlan{ - // Hardware v1 serial roles: uart0 is the CM5 Fabric/message bus; - // uart1 is the human-readable diagnostics mirror. + // RP2040 default pins for Pico {ID: "uart0", TX: 0, RX: 1, Baud: 115_200}, {ID: "uart1", TX: 4, RX: 5, Baud: 115_200}, }, @@ -42,7 +41,7 @@ var SelectedSetup = types.HALConfig{ {ID: "die_temp", Type: "rp2_temp", Params: rp2_temp.Params{Domain: "env", Name: "die"}}, - // Raw serial device bound to uart0: CM5 Fabric/message bus. + // Raw serial device bound to uart0 (public address hal/cap/io/serial/uart0/…) {ID: "uart0_raw", Type: "serial_raw", Params: serialraw.Params{ Bus: "uart0", Domain: "io", @@ -52,7 +51,7 @@ var SelectedSetup = types.HALConfig{ TXSize: rawSerialSessionSize, }}, - // Raw serial device bound to uart1: human-readable diagnostics. + // Raw serial device bound to uart1 (public address hal/cap/io/serial/uart1/…) {ID: "uart1_raw", Type: "serial_raw", Params: serialraw.Params{ Bus: "uart1", Domain: "io", diff --git a/services/reactor/qa_reactor.go b/services/reactor/qa_reactor.go index 91d9686..813779f 100644 --- a/services/reactor/qa_reactor.go +++ b/services/reactor/qa_reactor.go @@ -131,7 +131,8 @@ type Reactor struct { uiConn *bus.Connection // UART - // Human-readable logs are mirrored to uart1. Legacy JSON telemetry is not emitted. + jsonOut *shmring.Ring // telemetry (JSON UART TX) + // Logger UART1 already handled by global logger (see SetUART1) // inputs (latest) vin_mV, vbat_mV int32 @@ -161,6 +162,9 @@ type Reactor struct { // misc now time.Time + + // telemetry drop counters (bytes) + droppedUART0Bytes int } func NewReactor(uiConn *bus.Connection) *Reactor { @@ -316,13 +320,66 @@ func (r *Reactor) stepLED() { } } -// ---- public input updaters ---- +// ---- public input updaters (emit telemetry) ---- func (r *Reactor) OnCharger(v types.ChargerValue) { r.vin_mV = v.VIN_mV r.iin_mA = v.IIn_mA r.tsVIN = r.now + // JSON: {"power/charger/internal/vin":..,"vsys":..,"iin":..} + if r.jsonOut != nil { + var w utilities.JSONWriter + w.Write = r.jsonWrite + w.Begin() + w.KvInt("power/charger/internal/vin", int(v.VIN_mV)) + w.KvInt("power/charger/internal/vsys", int(v.VSYS_mV)) + w.KvInt("power/charger/internal/iin", int(v.IIn_mA)) + // Full bitfield maps (0/1) for LOCF pipelines + { + it := types.NewBitIter(types.SystemStatus(v.Sys), types.SystemStatusTable[:]) + for { + bitName, set, ok := it.NextAny() + if !ok { + break + } + if set { + w.KvInt("power/charger/internal/system/"+bitName, 1) + } else { + w.KvInt("power/charger/internal/system/"+bitName, 0) + } + } + } + { + it := types.NewBitIter(types.ChargeStatusBits(v.Status), types.ChargeStatusTable[:]) + for { + bitName, set, ok := it.NextAny() + if !ok { + break + } + if set { + w.KvInt("power/charger/internal/status/"+bitName, 1) + } else { + w.KvInt("power/charger/internal/status/"+bitName, 0) + } + } + } + { + it := types.NewBitIter(types.ChargerStateBits(v.State), types.ChargerStateTable[:]) + for { + bitName, set, ok := it.NextAny() + if !ok { + break + } + if set { + w.KvInt("power/charger/internal/state/"+bitName, 1) + } else { + w.KvInt("power/charger/internal/state/"+bitName, 0) + } + } + } + w.End() + } } func (r *Reactor) OnBattery(v types.BatteryValue) { @@ -330,13 +387,30 @@ func (r *Reactor) OnBattery(v types.BatteryValue) { r.ibat_mA = v.IBatMilliA r.tsVBAT = r.now + // JSON: {"power/battery/internal/vbat":..,"ibat":..} + if r.jsonOut != nil { + var w utilities.JSONWriter + w.Write = r.jsonWrite + w.Begin() + w.KvInt("power/battery/internal/vbat", int(v.PackMilliV)) + w.KvInt("power/battery/internal/ibat", int(v.IBatMilliA)) + w.KvInt("power/battery/internal/bsr", int(v.BSR_uOhmPerCell)) + w.End() + } } func (r *Reactor) OnTempDeciC(label string, deci int, jsonKey string) { log.Deci(label, deci) + if r.jsonOut != nil { + var w utilities.JSONWriter + w.Write = r.jsonWrite + w.Begin() + w.KvInt(jsonKey, deci) + w.End() + } } -// ---- memory snapshot (every ~2 s in main loop) ---- +// ---- memory snapshot telemetry (every ~2 s in main loop) ---- func (r *Reactor) emitMemSnapshot() { var ms runtime.MemStats @@ -350,6 +424,14 @@ func (r *Reactor) emitMemSnapshot() { "mallocs:", int(ms.Mallocs), " ", "frees:", int(ms.Frees), ) + // JSON (minimal to keep overhead low) + if r.jsonOut != nil { + var w utilities.JSONWriter + w.Write = r.jsonWrite + w.Begin() + w.KvInt("sys/mem/alloc", int(ms.Alloc)) + w.End() + } } func (r *Reactor) Run(ctx context.Context) { @@ -362,16 +444,22 @@ func (r *Reactor) Run(ctx context.Context) { stSub := r.uiConn.Subscribe(stTopic) evSub := r.uiConn.Subscribe(evTopic) - // UART session for human-readable diagnostics. - const uartLog = "uart1" + // UART sessions (TX only needed for our use) + const ( + uartTele = "uart0" // telemetry JSON + uartLog = "uart1" // log mirror + ) + subSessOpenTele := r.uiConn.Subscribe(tSessOpened(uartTele)) subSessOpenLog := r.uiConn.Subscribe(tSessOpened(uartLog)) + subSessClosedTele := r.uiConn.Subscribe(tSessClosed(uartTele)) subSessClosedLog := r.uiConn.Subscribe(tSessClosed(uartLog)) - // Kick open request (fire-and-forget; events carry handles). + // Kick open requests (fire-and-forget; events carry handles) + r.uiConn.Publish(r.uiConn.NewMessage(tSessOpen(uartTele), nil, false)) r.uiConn.Publish(r.uiConn.NewMessage(tSessOpen(uartLog), nil, false)) - // Retry back-off guard. - var retryLogAt time.Time + // Retry back-off guards + var retryTeleAt, retryLogAt time.Time // Supervisory ticker ticker := time.NewTicker(TICK) @@ -382,11 +470,24 @@ func (r *Reactor) Run(ctx context.Context) { for { select { // ---- UART session opened/closed ---- + case m := <-subSessOpenTele.Channel(): + if ev, ok := m.Payload.(types.SerialSessionOpened); ok { + r.jsonOut = shmring.Get(shmring.Handle(ev.TXHandle)) + log.Println("[uart0] telemetry session opened") + } case m := <-subSessOpenLog.Channel(): if ev, ok := m.Payload.(types.SerialSessionOpened); ok { log.SetUART1(shmring.Get(shmring.Handle(ev.TXHandle))) log.Println("[uart1] log session opened") } + case <-subSessClosedTele.Channel(): + r.jsonOut = nil + log.Println("[uart0] telemetry session closed") + // Auto-reopen with back-off + if time.Now().After(retryTeleAt) { + r.uiConn.Publish(r.uiConn.NewMessage(tSessOpen(uartTele), nil, false)) + retryTeleAt = time.Now().Add(2 * time.Second) + } case <-subSessClosedLog.Channel(): log.SetUART1(nil) log.Println("[uart1] log session closed") @@ -411,6 +512,14 @@ func (r *Reactor) Run(ctx context.Context) { case m := <-humidSub.Channel(): if v, ok := m.Payload.(types.HumidityValue); ok { log.Hundredths("[value] env/humidity/core %RH=", int(v.RHx100)) + // JSON + if r.jsonOut != nil { + var w utilities.JSONWriter + w.Write = r.jsonWrite + w.Begin() + w.KvInt("env/humidity/core", int(v.RHx100)) + w.End() + } } // ---- Die Temp Backup ---- @@ -445,6 +554,20 @@ func (r *Reactor) Run(ctx context.Context) { case m := <-evSub.Channel(): printCapEvent(m) + // JSON: {"///event":""} + if r.jsonOut != nil { + dom, _ := m.Topic.At(2).(string) + kind, _ := m.Topic.At(3).(string) + name, _ := m.Topic.At(4).(string) + tag, _ := m.Topic.At(6).(string) + if dom != "" && kind != "" && name != "" && tag != "" { + var w utilities.JSONWriter + w.Write = r.jsonWrite + w.Begin() + w.KvStr(dom+"/"+kind+"/"+name+"/event", tag) + w.End() + } + } // ---- Supervisory tick ---- case <-ticker.C: @@ -467,6 +590,26 @@ func (r *Reactor) Run(ctx context.Context) { } } +// ----------------------------------------------------------------------------- +// Centralised UART write helpers (handle partial writes) +// ----------------------------------------------------------------------------- + +// uart0 (telemetry JSON) — returns bytes written; tracks dropped bytes on partial writes. +func (r *Reactor) jsonWrite(b []byte) int { + if r == nil || r.jsonOut == nil || len(b) == 0 { + return 0 + } + n := r.jsonOut.TryWriteFrom(b) + if n < len(b) { + r.droppedUART0Bytes += (len(b) - n) + // Rate-limited note + if r.droppedUART0Bytes == (len(b)-n) || (r.droppedUART0Bytes%1024) == 0 { + log.Println("[uart0] dropped bytes =", r.droppedUART0Bytes) + } + } + return n +} + // ----------------------------------------------------------------------------- // Printing helpers (via Logger) // ----------------------------------------------------------------------------- diff --git a/services/reactor/reactor.go b/services/reactor/reactor.go index 5eadac7..8065982 100644 --- a/services/reactor/reactor.go +++ b/services/reactor/reactor.go @@ -170,6 +170,9 @@ type Reactor struct { // misc now time.Time + // telemetry drop counters (bytes) + droppedUART0Bytes int + // supervised children. The Reactor owns only lifecycle; child // services own their own event loops and models. children childSupervisor @@ -383,13 +386,14 @@ func (r *Reactor) stepLED() { } } -// ---- public input updaters ---- +// ---- public input updaters (emit telemetry) ---- func (r *Reactor) OnCharger(v types.ChargerValue) { r.vin_mV = v.VIN_mV r.iin_mA = v.IIn_mA r.tsVIN = r.now + // JSON: {"power/charger/internal/vin":..,"vsys":..,"iin":..} } func (r *Reactor) OnBattery(v types.BatteryValue) { @@ -397,13 +401,14 @@ func (r *Reactor) OnBattery(v types.BatteryValue) { r.ibat_mA = v.IBatMilliA r.tsVBAT = r.now + // JSON: {"power/battery/internal/vbat":..,"ibat":..} } func (r *Reactor) OnTempDeciC(label string, deci int, jsonKey string) { log.Deci(label, deci) } -// ---- memory snapshot (every ~2 s in main loop) ---- +// ---- memory snapshot telemetry (every ~2 s in main loop) ---- func (r *Reactor) emitMemSnapshot() { var ms runtime.MemStats @@ -417,6 +422,7 @@ func (r *Reactor) emitMemSnapshot() { "mallocs:", int(ms.Mallocs), " ", "frees:", int(ms.Frees), ) + // JSON (minimal to keep overhead low) } func (r *Reactor) Run(ctx context.Context) { @@ -505,6 +511,7 @@ func (r *Reactor) Run(ctx context.Context) { case m := <-humidSub.Channel(): if v, ok := m.Payload.(types.HumidityValue); ok { log.Hundredths("[value] env/humidity/core %RH=", int(v.RHx100)) + // JSON } // ---- Die Temp Backup ---- @@ -539,6 +546,7 @@ func (r *Reactor) Run(ctx context.Context) { case m := <-evSub.Channel(): printCapEvent(m) + // JSON: {"///event":""} // ---- Child service lifecycle ---- case ev := <-r.children.Done(): @@ -568,6 +576,10 @@ func (r *Reactor) Run(ctx context.Context) { } } +// ----------------------------------------------------------------------------- +// Centralised UART write helpers (handle partial writes) +// ----------------------------------------------------------------------------- + // ----------------------------------------------------------------------------- // Printing helpers (via Logger) // ----------------------------------------------------------------------------- From 2f729db1e96fbada290f8b74328f24b5616155ac Mon Sep 17 00:00:00 2001 From: Rich Thanki Date: Tue, 16 Jun 2026 13:54:06 +0100 Subject: [PATCH 14/17] light tracing + reply required --- services/otadiag/otadiag.go | 2 ++ services/otadiag/otadiag_test.go | 2 ++ services/updater/applier_host.go | 4 ++++ services/updater/applier_tinygo.go | 11 +++++++++ services/updater/rpc.go | 36 ++++++++++++++++++++++++++++++ 5 files changed, 55 insertions(+) diff --git a/services/otadiag/otadiag.go b/services/otadiag/otadiag.go index b6407fe..803cd55 100644 --- a/services/otadiag/otadiag.go +++ b/services/otadiag/otadiag.go @@ -246,6 +246,8 @@ func allowEvent(prefix, event string, fields []Field) bool { switch prefix { case "[mcu-ota]": return event == "heartbeat_start" || event == "heartbeat_stop" + case "[updater-commit]": + return true case "[serial-raw]", "[fabric-rx]", "[fabric-rpc]", "[fabric-handshake]", "[fabric-xfer]", "[updater-stream]": return strings.HasSuffix(event, "_error") || strings.Contains(event, "reject") || diff --git a/services/otadiag/otadiag_test.go b/services/otadiag/otadiag_test.go index 5b44c5f..02139d8 100644 --- a/services/otadiag/otadiag_test.go +++ b/services/otadiag/otadiag_test.go @@ -38,6 +38,7 @@ func TestDefaultFilterKeepsActionableEvents(t *testing.T) { Event("[fabric-xfer]", "sink_write_error", "xfer-1", KV("reason", "write_boom")) Event("[updater-stream]", "prepare_reject", XferNone, KV("reason", "busy")) Event("[updater-stream]", "image_signature_verify_error", "xfer-1", KV("reason", "bad_signature")) + Event("[updater-commit]", "rx", XferNone, KV("job_id", "job-1")) got := strings.Join(*lines, "\n") for _, want := range []string{ @@ -45,6 +46,7 @@ func TestDefaultFilterKeepsActionableEvents(t *testing.T) { "ev heartbeat_start", "ev heartbeat_stop", "ev xfer_abort", "ev sink_write_error", "ev prepare_reject", "ev image_signature_verify_error", + "[updater-commit]", "ev rx", } { if !strings.Contains(got, want) { t.Fatalf("default filter output missing %q:\n%s", want, got) diff --git a/services/updater/applier_host.go b/services/updater/applier_host.go index d50ba05..6e8c5a1 100644 --- a/services/updater/applier_host.go +++ b/services/updater/applier_host.go @@ -2,6 +2,8 @@ package updater +import "devicecode-go/services/otadiag" + // ProductionApplier returns the applier the reactor wires by default. // On host builds (tests, dev environments without a flash slot to // reboot into) this stays the safe-default RefusingApplier — commit @@ -10,7 +12,9 @@ package updater func ProductionApplier() Applier { return RefusingApplier() } func scheduleArmReboot(a Applier, d StagedDescriptor, results chan<- applyRebootResult) { + otadiag.Event("[updater-commit]", "arm_reboot_start", otadiag.XferNone, otadiag.KV("image_id", d.ImageID), otadiag.KV("slot", int(d.Slot))) if err := a.ArmReboot(d); err != nil { + otadiag.Event("[updater-commit]", "arm_reboot_return", otadiag.XferNone, otadiag.KV("err", err.Error()), otadiag.KV("image_id", d.ImageID), otadiag.KV("slot", int(d.Slot))) select { case results <- applyRebootResult{desc: d, err: err}: default: diff --git a/services/updater/applier_tinygo.go b/services/updater/applier_tinygo.go index b5b5336..7d152e4 100644 --- a/services/updater/applier_tinygo.go +++ b/services/updater/applier_tinygo.go @@ -5,6 +5,8 @@ package updater import ( "errors" "time" + + "devicecode-go/services/otadiag" ) // abupdateApplier reboots into the slot the abupdateSink staged into. @@ -40,13 +42,22 @@ func (abupdateApplier) ArmReboot(d StagedDescriptor) error { } func scheduleArmReboot(a Applier, d StagedDescriptor, results chan<- applyRebootResult) { + otadiag.Event( + "[updater-commit]", "arm_reboot_scheduled", otadiag.XferNone, + otadiag.KV("delay_ms", postCommitReplyFlushDelay), + otadiag.KV("image_id", d.ImageID), + otadiag.KV("version", d.Version), + otadiag.KV("slot", int(d.Slot)), + ) go func() { // handleCommit has only replied on the local bus. The fabric // session still needs a scheduler turn to marshal and write the // wire reply (and the state=rebooting retain) back to CM5 before // RebootIntoSlot stops the process. time.Sleep(postCommitReplyFlushDelay) + otadiag.Event("[updater-commit]", "arm_reboot_start", otadiag.XferNone, otadiag.KV("image_id", d.ImageID), otadiag.KV("slot", int(d.Slot))) if err := a.ArmReboot(d); err != nil { + otadiag.Event("[updater-commit]", "arm_reboot_return", otadiag.XferNone, otadiag.KV("err", err.Error()), otadiag.KV("image_id", d.ImageID), otadiag.KV("slot", int(d.Slot))) select { case results <- applyRebootResult{desc: d, err: err}: default: diff --git a/services/updater/rpc.go b/services/updater/rpc.go index cc0b887..2b96441 100644 --- a/services/updater/rpc.go +++ b/services/updater/rpc.go @@ -103,22 +103,36 @@ func (s *Service) handlePrepare(msg *bus.Message) { func (s *Service) handleCommit(msg *bus.Message) { req, ok := jsonDecode[CommitRequest](msg.Payload) if !ok { + otadiag.Event("[updater-commit]", "reject", otadiag.XferNone, otadiag.KV("reason", ErrInvalidRequest)) s.reply(msg, Reply{OK: false, Error: ErrInvalidRequest}) return } + otadiag.Event( + "[updater-commit]", "rx", otadiag.XferNone, + otadiag.KV("job_id", req.JobID), + otadiag.KV("expected_image_id", req.ExpectedImageID), + ) desc, present := s.metadata.StagedDescriptor() s.mu.Lock() stagedInState := s.state == StateStaged + state := s.state pendingImageID := s.pendingImageID streamActive := s.streamLeaseActive s.mu.Unlock() if streamActive { + otadiag.Event("[updater-commit]", "reject", otadiag.XferNone, otadiag.KV("reason", ErrBusy), otadiag.KV("stream_active", true)) s.reply(msg, Reply{OK: false, Error: ErrBusy}) return } if !present || !stagedInState { + otadiag.Event( + "[updater-commit]", "reject", otadiag.XferNone, + otadiag.KV("reason", ErrNoStagedImage), + otadiag.KV("staged_present", present), + otadiag.KV("state", string(state)), + ) s.reply(msg, Reply{OK: false, Error: ErrNoStagedImage}) return } @@ -127,6 +141,12 @@ func (s *Service) handleCommit(msg *bus.Message) { expectedImageID = req.ExpectedImageID } if expectedImageID != "" && desc.ImageID != expectedImageID { + otadiag.Event( + "[updater-commit]", "reject", otadiag.XferNone, + otadiag.KV("reason", ErrImageIDMismatch), + otadiag.KV("staged_image_id", desc.ImageID), + otadiag.KV("expected_image_id", expectedImageID), + ) s.reply(msg, Reply{OK: false, Error: ErrImageIDMismatch}) return } @@ -134,13 +154,29 @@ func (s *Service) handleCommit(msg *bus.Message) { // Validate the apply path before publishing committing/rebooting or // replying accepted. The default Applier refuses in non-hardware tests. if err := s.applier.CanApply(desc); err != nil { + otadiag.Event( + "[updater-commit]", "reject", otadiag.XferNone, + otadiag.KV("reason", ErrApplyUnavailable), + otadiag.KV("err", err.Error()), + otadiag.KV("image_id", desc.ImageID), + otadiag.KV("version", desc.Version), + ) s.reply(msg, Reply{OK: false, Error: ErrApplyUnavailable}) return } + otadiag.Event( + "[updater-commit]", "accepted", otadiag.XferNone, + otadiag.KV("job_id", req.JobID), + otadiag.KV("image_id", desc.ImageID), + otadiag.KV("version", desc.Version), + otadiag.KV("length", desc.Length), + otadiag.KV("slot", int(desc.Slot)), + ) s.transitionTo(StateCommitting, "", desc.Version) s.reply(msg, CommitReply{Accepted: true, RebootRequired: true}) s.transitionTo(StateRebooting, "", desc.Version) + otadiag.Event("[updater-commit]", "state", otadiag.XferNone, otadiag.KV("state", string(StateRebooting)), otadiag.KV("image_id", desc.ImageID)) scheduleArmReboot(s.applier, desc, s.applyResults) } From 4904423dce44534be526307cab033f6872411e9c Mon Sep 17 00:00:00 2001 From: Rich Thanki Date: Tue, 16 Jun 2026 14:57:18 +0100 Subject: [PATCH 15/17] minimal commit payload --- services/updater/types.go | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/services/updater/types.go b/services/updater/types.go index d7b66c4..d396469 100644 --- a/services/updater/types.go +++ b/services/updater/types.go @@ -48,11 +48,12 @@ type PrepareRequest struct { Metadata any `json:"metadata,omitempty"` } -// CommitRequest mirrors commit-update. +// CommitRequest mirrors the strict commit-update payload. Commit is deliberately +// minimal: the MCU decides from its staged descriptor and the optional expected +// image id. Arbitrary metadata belongs to prepare/stage, not commit. type CommitRequest struct { JobID string `json:"job_id,omitempty"` ExpectedImageID string `json:"expected_image_id,omitempty"` - Metadata any `json:"metadata,omitempty"` } type PrepareReply struct { From 8381fcbd248f1f8235abb9a6fc64f120c4e450da Mon Sep 17 00:00:00 2001 From: Rich Thanki Date: Tue, 16 Jun 2026 22:59:33 +0100 Subject: [PATCH 16/17] cross codebase testing harness --- cmd/mcu-devhost-pty/applier.go | 56 +++++++++++ cmd/mcu-devhost-pty/dcmcu.go | 132 ++++++++++++++++++++++++++ cmd/mcu-devhost-pty/dcmcu_test.go | 89 +++++++++++++++++ cmd/mcu-devhost-pty/main.go | 87 +++++++++++++++++ cmd/mcu-devhost-pty/state.go | 124 ++++++++++++++++++++++++ cmd/mcu-devhost-pty/state_test.go | 63 ++++++++++++ cmd/mcu-devhost-pty/transport.go | 81 ++++++++++++++++ cmd/mcu-devhost-pty/transport_test.go | 46 +++++++++ 8 files changed, 678 insertions(+) create mode 100644 cmd/mcu-devhost-pty/applier.go create mode 100644 cmd/mcu-devhost-pty/dcmcu.go create mode 100644 cmd/mcu-devhost-pty/dcmcu_test.go create mode 100644 cmd/mcu-devhost-pty/main.go create mode 100644 cmd/mcu-devhost-pty/state.go create mode 100644 cmd/mcu-devhost-pty/state_test.go create mode 100644 cmd/mcu-devhost-pty/transport.go create mode 100644 cmd/mcu-devhost-pty/transport_test.go diff --git a/cmd/mcu-devhost-pty/applier.go b/cmd/mcu-devhost-pty/applier.go new file mode 100644 index 0000000..1b67e62 --- /dev/null +++ b/cmd/mcu-devhost-pty/applier.go @@ -0,0 +1,56 @@ +//go:build !tinygo + +package main + +import ( + "encoding/json" + "errors" + "fmt" + "os" + + "devicecode-go/services/updater" +) + +type devhostApplier struct { + store *stateStore + exitCode int + exit func(int) +} + +func (a devhostApplier) CanApply(d updater.StagedDescriptor) error { + if d.ImageID == "" { + return errors.New("staged_image_id_required") + } + if d.Length == 0 { + return errors.New("staged_length_required") + } + if d.PayloadSHA256 == "" { + return errors.New("staged_payload_sha256_required") + } + return nil +} + +func (a devhostApplier) ArmReboot(d updater.StagedDescriptor) error { + if a.store == nil { + return errors.New("devhost_state_store_required") + } + if err := a.store.MarkRunningFromStaged(d); err != nil { + return err + } + logJSON(map[string]any{"event": "rebooting", "image_id": d.ImageID, "version": d.Version, "length": d.Length}) + if a.exit != nil { + a.exit(a.exitCode) + return nil + } + os.Exit(a.exitCode) + return nil +} + +func logJSON(v any) { + b, err := json.Marshal(v) + if err != nil { + fmt.Printf("{\"event\":\"log_error\",\"err\":%q}\n", err.Error()) + return + } + fmt.Println(string(b)) +} diff --git a/cmd/mcu-devhost-pty/dcmcu.go b/cmd/mcu-devhost-pty/dcmcu.go new file mode 100644 index 0000000..13d51b3 --- /dev/null +++ b/cmd/mcu-devhost-pty/dcmcu.go @@ -0,0 +1,132 @@ +//go:build !tinygo + +package main + +import ( + "crypto/sha256" + "encoding/binary" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + + "devicecode-go/services/updater" +) + +const ( + dcmcuMagic = "DCMCUIMG" + dcmcuFormatVersion = uint16(1) + dcmcuMinHeaderLen = 16 + dcmcuMaxHeaderLen = 4096 + dcmcuMaxManifestLen = 1024 * 1024 +) + +type dcmcuManifest struct { + Schema int `json:"schema"` + Component string `json:"component"` + Build struct { + Version string `json:"version"` + BuildID string `json:"build_id"` + ImageID string `json:"image_id"` + } `json:"build"` + Payload struct { + Length uint32 `json:"length"` + SHA256 string `json:"sha256"` + } `json:"payload"` +} + +type devhostDCMCUVerifier struct{} + +func (devhostDCMCUVerifier) Verify(r io.Reader, sink updater.SlotSink) (updater.Manifest, error) { + if sink == nil { + return updater.Manifest{}, errors.New("devhost_dcmcu: nil sink") + } + manifest, payload, err := readDCMCU(r) + if err != nil { + _ = sink.Abort() + return updater.Manifest{}, err + } + if _, err := sink.Write(payload); err != nil { + _ = sink.Abort() + return updater.Manifest{}, err + } + if err := sink.Commit(); err != nil { + _ = sink.Abort() + return updater.Manifest{}, err + } + return updater.Manifest{ + Version: manifest.Build.Version, + BuildID: manifest.Build.BuildID, + ImageID: manifest.Build.ImageID, + PayloadSHA256: manifest.Payload.SHA256, + PayloadLength: manifest.Payload.Length, + }, nil +} + +func readDCMCU(r io.Reader) (dcmcuManifest, []byte, error) { + var zero dcmcuManifest + header16 := make([]byte, dcmcuMinHeaderLen) + if _, err := io.ReadFull(r, header16); err != nil { + return zero, nil, fmt.Errorf("dcmcu_header: %w", err) + } + if string(header16[:len(dcmcuMagic)]) != dcmcuMagic { + return zero, nil, errors.New("dcmcu_bad_magic") + } + version := binary.LittleEndian.Uint16(header16[8:10]) + headerLen := binary.LittleEndian.Uint16(header16[10:12]) + manifestLen := binary.LittleEndian.Uint32(header16[12:16]) + if version != dcmcuFormatVersion { + return zero, nil, errors.New("dcmcu_version_unsupported") + } + if headerLen < dcmcuMinHeaderLen || headerLen > dcmcuMaxHeaderLen { + return zero, nil, errors.New("dcmcu_header_len_invalid") + } + if manifestLen == 0 || manifestLen > dcmcuMaxManifestLen { + return zero, nil, errors.New("dcmcu_manifest_len_invalid") + } + if extra := int(headerLen) - len(header16); extra > 0 { + if _, err := io.CopyN(io.Discard, r, int64(extra)); err != nil { + return zero, nil, fmt.Errorf("dcmcu_header_rest: %w", err) + } + } + manifestRaw := make([]byte, manifestLen) + if _, err := io.ReadFull(r, manifestRaw); err != nil { + return zero, nil, fmt.Errorf("dcmcu_manifest: %w", err) + } + var manifest dcmcuManifest + if err := json.Unmarshal(manifestRaw, &manifest); err != nil { + return zero, nil, fmt.Errorf("dcmcu_manifest_json_invalid: %w", err) + } + if err := validateDCMCUManifest(manifest); err != nil { + return zero, nil, err + } + payload := make([]byte, manifest.Payload.Length) + if _, err := io.ReadFull(r, payload); err != nil { + return zero, nil, fmt.Errorf("dcmcu_payload: %w", err) + } + sum := sha256.Sum256(payload) + if got := hex.EncodeToString(sum[:]); got != manifest.Payload.SHA256 { + return zero, nil, fmt.Errorf("dcmcu_payload_sha256_mismatch: got %s want %s", got, manifest.Payload.SHA256) + } + return manifest, payload, nil +} + +func validateDCMCUManifest(m dcmcuManifest) error { + if m.Schema != 1 { + return errors.New("dcmcu_manifest_schema_unsupported") + } + if m.Component != "mcu" { + return errors.New("dcmcu_component_not_mcu") + } + if m.Build.ImageID == "" { + return errors.New("dcmcu_image_id_required") + } + if len(m.Payload.SHA256) != 64 { + return errors.New("dcmcu_payload_sha256_invalid") + } + if _, err := hex.DecodeString(m.Payload.SHA256); err != nil { + return errors.New("dcmcu_payload_sha256_invalid") + } + return nil +} diff --git a/cmd/mcu-devhost-pty/dcmcu_test.go b/cmd/mcu-devhost-pty/dcmcu_test.go new file mode 100644 index 0000000..559f1f4 --- /dev/null +++ b/cmd/mcu-devhost-pty/dcmcu_test.go @@ -0,0 +1,89 @@ +//go:build !tinygo + +package main + +import ( + "bytes" + "crypto/sha256" + "encoding/binary" + "encoding/hex" + "encoding/json" + "strings" + "testing" +) + +func makeDCMCUForTest(t *testing.T, imageID string, payload []byte) []byte { + t.Helper() + sum := sha256.Sum256(payload) + manifest, err := json.Marshal(map[string]any{ + "schema": 1, + "component": "mcu", + "build": map[string]any{ + "version": "15.0", + "build_id": "test-build", + "image_id": imageID, + }, + "payload": map[string]any{ + "length": len(payload), + "sha256": hex.EncodeToString(sum[:]), + }, + }) + if err != nil { + t.Fatal(err) + } + headerLen := uint16(32) + header := make([]byte, headerLen) + copy(header, []byte(dcmcuMagic)) + binary.LittleEndian.PutUint16(header[8:10], dcmcuFormatVersion) + binary.LittleEndian.PutUint16(header[10:12], headerLen) + binary.LittleEndian.PutUint32(header[12:16], uint32(len(manifest))) + out := append(header, manifest...) + out = append(out, payload...) + return out +} + +func TestReadDCMCUExtractsManifestAndVerifiesPayload(t *testing.T) { + payload := []byte(strings.Repeat("payload-", 64)) + m, got, err := readDCMCU(bytes.NewReader(makeDCMCUForTest(t, "mcu-dev-15.0", payload))) + if err != nil { + t.Fatalf("readDCMCU: %v", err) + } + if m.Build.ImageID != "mcu-dev-15.0" || m.Build.Version != "15.0" || m.Build.BuildID != "test-build" { + t.Fatalf("manifest build = %+v", m.Build) + } + if !bytes.Equal(got, payload) { + t.Fatalf("payload mismatch") + } +} + +func TestReadDCMCURejectsPayloadHashMismatch(t *testing.T) { + blob := makeDCMCUForTest(t, "mcu-dev-15.0", []byte("payload")) + blob[len(blob)-1] ^= 0xff + _, _, err := readDCMCU(bytes.NewReader(blob)) + if err == nil || !strings.Contains(err.Error(), "dcmcu_payload_sha256_mismatch") { + t.Fatalf("err = %v, want payload hash mismatch", err) + } +} + +type recordingSink struct { + bytes.Buffer + committed, aborted bool +} + +func (s *recordingSink) Commit() error { s.committed = true; return nil } +func (s *recordingSink) Abort() error { s.aborted = true; return nil } + +func TestDevhostVerifierStreamsPayloadIntoSink(t *testing.T) { + payload := []byte("verified-payload") + sink := &recordingSink{} + manifest, err := (devhostDCMCUVerifier{}).Verify(bytes.NewReader(makeDCMCUForTest(t, "mcu-dev-16.0", payload)), sink) + if err != nil { + t.Fatalf("Verify: %v", err) + } + if manifest.ImageID != "mcu-dev-16.0" || manifest.PayloadLength != uint32(len(payload)) { + t.Fatalf("manifest = %+v", manifest) + } + if !sink.committed || sink.aborted || sink.String() != string(payload) { + t.Fatalf("sink committed=%v aborted=%v payload=%q", sink.committed, sink.aborted, sink.String()) + } +} diff --git a/cmd/mcu-devhost-pty/main.go b/cmd/mcu-devhost-pty/main.go new file mode 100644 index 0000000..be9328d --- /dev/null +++ b/cmd/mcu-devhost-pty/main.go @@ -0,0 +1,87 @@ +//go:build !tinygo + +package main + +import ( + "context" + "errors" + "flag" + "fmt" + "os" + "os/signal" + "runtime" + "syscall" + + "devicecode-go/bus" + "devicecode-go/services/fabric" + "devicecode-go/services/updater" +) + +func main() { + if err := run(); err != nil { + fmt.Fprintf(os.Stderr, "mcu-devhost-pty: %v\n", err) + os.Exit(2) + } +} + +func run() error { + var cfg struct { + uart string + stateDir string + node string + peer string + initialImageID string + initialVersion string + initialBuildID string + rebootExitCode int + } + flag.StringVar(&cfg.uart, "uart", "", "POSIX tty path connected to the CM5-side PTY") + flag.StringVar(&cfg.stateDir, "state-dir", "", "directory for devhost MCU state.json") + flag.StringVar(&cfg.node, "node", "mcu", "local Fabric node id") + flag.StringVar(&cfg.peer, "peer", "bigbox-cm5", "expected peer Fabric node id") + flag.StringVar(&cfg.initialImageID, "initial-image-id", "mcu-dev-10.0", "initial running image id when state-dir is empty") + flag.StringVar(&cfg.initialVersion, "initial-version", "10.0", "initial running version when state-dir is empty") + flag.StringVar(&cfg.initialBuildID, "initial-build-id", "devhost-initial", "initial running build id when state-dir is empty") + flag.IntVar(&cfg.rebootExitCode, "reboot-exit-code", 42, "exit code used to model MCU reboot after commit") + flag.Parse() + + if cfg.uart == "" { + return errors.New("--uart is required") + } + store, err := openStateStore(cfg.stateDir, imageState{ImageID: cfg.initialImageID, Version: cfg.initialVersion, BuildID: cfg.initialBuildID}) + if err != nil { + return err + } + tr, err := openLineTransport(cfg.uart) + if err != nil { + return err + } + defer tr.Close() + + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + + _ = updater.GenerateBootID() + identity := store.identity() + logJSON(map[string]any{ + "event": "ready", "node": cfg.node, "peer": cfg.peer, + "image_id": identity.ImageID, "version": identity.Version, "build_id": identity.Build, + "gomaxprocs": runtime.GOMAXPROCS(0), + }) + + b := bus.NewBus(64, "+", "#") + updaterConn := b.NewConnection("updater") + fabricConn := b.NewConnection("fabric") + + svc := updater.New(updater.Options{ + Conn: updaterConn, + Verifier: devhostDCMCUVerifier{}, + Applier: devhostApplier{store: store, exitCode: cfg.rebootExitCode}, + Metadata: store, + MetadataWrite: store, + Identity: identity, + }) + go svc.Run(ctx) + fabric.RunWithOptions(ctx, tr, fabricConn, cfg.node, cfg.peer, fabric.DefaultLinkConfig(), fabric.RunOptions{StageController: svc}) + return nil +} diff --git a/cmd/mcu-devhost-pty/state.go b/cmd/mcu-devhost-pty/state.go new file mode 100644 index 0000000..379c29d --- /dev/null +++ b/cmd/mcu-devhost-pty/state.go @@ -0,0 +1,124 @@ +//go:build !tinygo + +package main + +import ( + "encoding/json" + "errors" + "os" + "path/filepath" + "sync" + + "devicecode-go/services/updater" +) + +type imageState struct { + ImageID string `json:"image_id"` + Version string `json:"version"` + BuildID string `json:"build_id"` + PayloadSHA256 string `json:"payload_sha256,omitempty"` +} + +type devhostStateFile struct { + BootSeq int `json:"boot_seq"` + Running imageState `json:"running"` + Staged *updater.StagedDescriptor `json:"staged,omitempty"` +} + +type stateStore struct { + mu sync.Mutex + dir string + path string + data devhostStateFile +} + +func openStateStore(dir string, initial imageState) (*stateStore, error) { + if dir == "" { + return nil, errors.New("state-dir required") + } + if initial.ImageID == "" { + return nil, errors.New("initial image id required") + } + if initial.Version == "" { + initial.Version = "0.0.0-devhost" + } + if initial.BuildID == "" { + initial.BuildID = "devhost-initial" + } + if err := os.MkdirAll(dir, 0o755); err != nil { + return nil, err + } + s := &stateStore{dir: dir, path: filepath.Join(dir, "state.json")} + b, err := os.ReadFile(s.path) + if err == nil { + if err := json.Unmarshal(b, &s.data); err != nil { + return nil, err + } + if s.data.Running.ImageID == "" { + return nil, errors.New("state running image_id missing") + } + return s, nil + } + if !errors.Is(err, os.ErrNotExist) { + return nil, err + } + s.data = devhostStateFile{BootSeq: 1, Running: initial} + return s, s.saveLocked() +} + +func (s *stateStore) identity() updater.Identity { + s.mu.Lock() + defer s.mu.Unlock() + return updater.Identity{Version: s.data.Running.Version, Build: s.data.Running.BuildID, ImageID: s.data.Running.ImageID} +} + +func (s *stateStore) PayloadSHA256() string { + s.mu.Lock() + defer s.mu.Unlock() + return s.data.Running.PayloadSHA256 +} + +func (s *stateStore) StagedDescriptor() (updater.StagedDescriptor, bool) { + s.mu.Lock() + defer s.mu.Unlock() + if s.data.Staged == nil { + return updater.StagedDescriptor{}, false + } + return *s.data.Staged, true +} + +func (s *stateStore) WriteStagedDescriptor(d updater.StagedDescriptor) error { + s.mu.Lock() + defer s.mu.Unlock() + s.data.Staged = &d + return s.saveLocked() +} + +func (s *stateStore) ClearStagedDescriptor() error { + s.mu.Lock() + defer s.mu.Unlock() + s.data.Staged = nil + return s.saveLocked() +} + +func (s *stateStore) MarkRunningFromStaged(d updater.StagedDescriptor) error { + s.mu.Lock() + defer s.mu.Unlock() + s.data.BootSeq++ + s.data.Running = imageState{ImageID: d.ImageID, Version: d.Version, BuildID: d.BuildID, PayloadSHA256: d.PayloadSHA256} + s.data.Staged = nil + return s.saveLocked() +} + +func (s *stateStore) saveLocked() error { + b, err := json.MarshalIndent(s.data, "", " ") + if err != nil { + return err + } + b = append(b, '\n') + tmp := s.path + ".tmp" + if err := os.WriteFile(tmp, b, 0o644); err != nil { + return err + } + return os.Rename(tmp, s.path) +} diff --git a/cmd/mcu-devhost-pty/state_test.go b/cmd/mcu-devhost-pty/state_test.go new file mode 100644 index 0000000..0ee952d --- /dev/null +++ b/cmd/mcu-devhost-pty/state_test.go @@ -0,0 +1,63 @@ +//go:build !tinygo + +package main + +import ( + "testing" + + "devicecode-go/services/updater" +) + +func TestStateStorePersistsRunningAndStagedDescriptor(t *testing.T) { + dir := t.TempDir() + store, err := openStateStore(dir, imageState{ImageID: "mcu-dev-10.0", Version: "10.0", BuildID: "initial"}) + if err != nil { + t.Fatalf("openStateStore: %v", err) + } + if id := store.identity(); id.ImageID != "mcu-dev-10.0" || id.Version != "10.0" { + t.Fatalf("identity = %+v", id) + } + desc := updater.StagedDescriptor{ImageID: "mcu-dev-15.0", Version: "15.0", BuildID: "build-15", Length: 1234, PayloadSHA256: "abc"} + if err := store.WriteStagedDescriptor(desc); err != nil { + t.Fatalf("WriteStagedDescriptor: %v", err) + } + got, ok := store.StagedDescriptor() + if !ok || got.ImageID != desc.ImageID { + t.Fatalf("staged = %+v ok=%v", got, ok) + } + + reopened, err := openStateStore(dir, imageState{ImageID: "ignored", Version: "ignored"}) + if err != nil { + t.Fatalf("reopen: %v", err) + } + got, ok = reopened.StagedDescriptor() + if !ok || got.ImageID != desc.ImageID { + t.Fatalf("reopened staged = %+v ok=%v", got, ok) + } +} + +func TestDevhostApplierMarksRunningAndExits(t *testing.T) { + store, err := openStateStore(t.TempDir(), imageState{ImageID: "mcu-dev-10.0", Version: "10.0"}) + if err != nil { + t.Fatal(err) + } + desc := updater.StagedDescriptor{ImageID: "mcu-dev-15.0", Version: "15.0", BuildID: "build-15", Length: 1234, PayloadSHA256: "sha"} + var exitCode int + applier := devhostApplier{store: store, exitCode: 42, exit: func(code int) { exitCode = code }} + if err := applier.CanApply(desc); err != nil { + t.Fatalf("CanApply: %v", err) + } + if err := applier.ArmReboot(desc); err != nil { + t.Fatalf("ArmReboot: %v", err) + } + if exitCode != 42 { + t.Fatalf("exitCode=%d", exitCode) + } + id := store.identity() + if id.ImageID != "mcu-dev-15.0" || id.Version != "15.0" || id.Build != "build-15" { + t.Fatalf("running identity = %+v", id) + } + if _, ok := store.StagedDescriptor(); ok { + t.Fatalf("staged descriptor was not cleared") + } +} diff --git a/cmd/mcu-devhost-pty/transport.go b/cmd/mcu-devhost-pty/transport.go new file mode 100644 index 0000000..bab96b0 --- /dev/null +++ b/cmd/mcu-devhost-pty/transport.go @@ -0,0 +1,81 @@ +//go:build !tinygo + +package main + +import ( + "bufio" + "errors" + "fmt" + "io" + "os" + "sync" +) + +const devhostMaxLineLen = 64 * 1024 + +// lineTransport adapts a host POSIX tty, pipe, or socket to Fabric's JSONL +// transport interface. It deliberately does not inject noise; the Lua test rig +// owns stream pressure and fragmentation so there is one place to reason about +// line conditions. +type lineTransport struct { + rwc io.ReadWriteCloser + r *bufio.Reader + mu sync.Mutex +} + +func openLineTransport(path string) (*lineTransport, error) { + if path == "" { + return nil, errors.New("uart path required") + } + f, err := os.OpenFile(path, os.O_RDWR, 0) + if err != nil { + return nil, err + } + return newLineTransport(f), nil +} + +func newLineTransport(rwc io.ReadWriteCloser) *lineTransport { + return &lineTransport{rwc: rwc, r: bufio.NewReaderSize(rwc, devhostMaxLineLen)} +} + +func (t *lineTransport) ReadLine() ([]byte, error) { + if t == nil || t.r == nil { + return nil, errors.New("transport closed") + } + line, err := t.r.ReadBytes('\n') + if err != nil { + return nil, err + } + if len(line) > 0 && line[len(line)-1] == '\n' { + line = line[:len(line)-1] + } + if len(line) > 0 && line[len(line)-1] == '\r' { + line = line[:len(line)-1] + } + if len(line) > devhostMaxLineLen { + return nil, fmt.Errorf("line too long: %d", len(line)) + } + out := make([]byte, len(line)) + copy(out, line) + return out, nil +} + +func (t *lineTransport) WriteLine(data []byte) error { + if len(data) > devhostMaxLineLen { + return fmt.Errorf("line too long: %d", len(data)) + } + t.mu.Lock() + defer t.mu.Unlock() + if _, err := t.rwc.Write(data); err != nil { + return err + } + _, err := t.rwc.Write([]byte{'\n'}) + return err +} + +func (t *lineTransport) Close() error { + if t == nil || t.rwc == nil { + return nil + } + return t.rwc.Close() +} diff --git a/cmd/mcu-devhost-pty/transport_test.go b/cmd/mcu-devhost-pty/transport_test.go new file mode 100644 index 0000000..ede85dc --- /dev/null +++ b/cmd/mcu-devhost-pty/transport_test.go @@ -0,0 +1,46 @@ +//go:build !tinygo + +package main + +import ( + "net" + "testing" + "time" +) + +func TestLineTransportReadsAndWritesJSONLines(t *testing.T) { + a, b := net.Pipe() + defer a.Close() + defer b.Close() + left := newLineTransport(a) + defer left.Close() + + go func() { + _, _ = b.Write([]byte("{\"type\":\"hello\"}\n")) + }() + line, err := left.ReadLine() + if err != nil { + t.Fatalf("ReadLine: %v", err) + } + if string(line) != `{"type":"hello"}` { + t.Fatalf("line = %q", string(line)) + } + + read := make(chan string, 1) + go func() { + buf := make([]byte, 64) + n, _ := b.Read(buf) + read <- string(buf[:n]) + }() + if err := left.WriteLine([]byte(`{"type":"pong"}`)); err != nil { + t.Fatalf("WriteLine: %v", err) + } + select { + case got := <-read: + if got != `{"type":"pong"}`+"\n" { + t.Fatalf("write = %q", got) + } + case <-time.After(time.Second): + t.Fatal("timeout waiting for write") + } +} From 49a3ee0341d4ef4dd5cfad3350f8a3e423d28c96 Mon Sep 17 00:00:00 2001 From: Rich Thanki Date: Wed, 17 Jun 2026 00:41:27 +0100 Subject: [PATCH 17/17] a-pplier wait --- services/updater/applier_host.go | 30 ++++++++++++++++++++++-------- 1 file changed, 22 insertions(+), 8 deletions(-) diff --git a/services/updater/applier_host.go b/services/updater/applier_host.go index 6e8c5a1..8cbc3fc 100644 --- a/services/updater/applier_host.go +++ b/services/updater/applier_host.go @@ -2,7 +2,11 @@ package updater -import "devicecode-go/services/otadiag" +import ( + "time" + + "devicecode-go/services/otadiag" +) // ProductionApplier returns the applier the reactor wires by default. // On host builds (tests, dev environments without a flash slot to @@ -12,12 +16,22 @@ import "devicecode-go/services/otadiag" func ProductionApplier() Applier { return RefusingApplier() } func scheduleArmReboot(a Applier, d StagedDescriptor, results chan<- applyRebootResult) { - otadiag.Event("[updater-commit]", "arm_reboot_start", otadiag.XferNone, otadiag.KV("image_id", d.ImageID), otadiag.KV("slot", int(d.Slot))) - if err := a.ArmReboot(d); err != nil { - otadiag.Event("[updater-commit]", "arm_reboot_return", otadiag.XferNone, otadiag.KV("err", err.Error()), otadiag.KV("image_id", d.ImageID), otadiag.KV("slot", int(d.Slot))) - select { - case results <- applyRebootResult{desc: d, err: err}: - default: + const replyFlushDelay = 750 * time.Millisecond + otadiag.Event( + "[updater-commit]", "arm_reboot_scheduled", otadiag.XferNone, + otadiag.KV("image_id", d.ImageID), + otadiag.KV("slot", int(d.Slot)), + otadiag.KV("delay_ms", int(replyFlushDelay/time.Millisecond)), + ) + go func() { + time.Sleep(replyFlushDelay) + otadiag.Event("[updater-commit]", "arm_reboot_start", otadiag.XferNone, otadiag.KV("image_id", d.ImageID), otadiag.KV("slot", int(d.Slot))) + if err := a.ArmReboot(d); err != nil { + otadiag.Event("[updater-commit]", "arm_reboot_return", otadiag.XferNone, otadiag.KV("err", err.Error()), otadiag.KV("image_id", d.ImageID), otadiag.KV("slot", int(d.Slot))) + select { + case results <- applyRebootResult{desc: d, err: err}: + default: + } } - } + }() }