From 93f249c0d6c4a45b7a3947c400cdb85e590a07f1 Mon Sep 17 00:00:00 2001 From: DRSDavidSoft <4673812+DRSDavidSoft@users.noreply.github.com> Date: Tue, 27 Oct 2026 00:28:57 +0330 Subject: [PATCH 01/19] feat: integrate strip designer and deliberate pixel diagnostics --- Tools/Controller/internal/control/strip.go | 26 +++++- .../Controller/internal/control/strip_test.go | 25 +++++ docs/STRIP-WORKSPACE-INTEGRATION.md | 37 ++++++++ mobile/GO-FLUTTER-PARITY.md | 6 ++ mobile/UI-REQUIREMENT-LEDGER.md | 10 ++ mobile/lib/unified/effect_editor.dart | 9 ++ mobile/lib/unified/go_tools.dart | 4 +- mobile/lib/unified/pixel_diagnostic.dart | 49 ++++++++++ mobile/lib/unified/strip_designer.dart | 93 +++++++++++++++++++ mobile/test/strip_designer_test.dart | 16 ++++ 10 files changed, 272 insertions(+), 3 deletions(-) create mode 100644 docs/STRIP-WORKSPACE-INTEGRATION.md create mode 100644 mobile/lib/unified/pixel_diagnostic.dart create mode 100644 mobile/lib/unified/strip_designer.dart create mode 100644 mobile/test/strip_designer_test.dart diff --git a/Tools/Controller/internal/control/strip.go b/Tools/Controller/internal/control/strip.go index e2fd00f6..8e02ff10 100644 --- a/Tools/Controller/internal/control/strip.go +++ b/Tools/Controller/internal/control/strip.go @@ -138,7 +138,7 @@ func stripStreamCommand(ctx context.Context, outputs *OutputScheduler, args []st return "", err } outputs.stop("strip") - if err = outputs.sendStripFrame(ctx, rgb); err != nil { + if err = outputs.sendConfiguredStripFrame(ctx, rgb); err != nil { return "", err } return fmt.Sprintf("strip frame displayed: %d LEDs", len(rgb)/3), nil @@ -272,12 +272,34 @@ func (outputs *OutputScheduler) streamStripProgram(ctx context.Context, count, f } func (outputs *OutputScheduler) sendStripFrame(ctx context.Context, rgb []byte) error { - frames, err := native.StripFramePayloads(rgb) + outputs.stripMu.Lock() + defer outputs.stripMu.Unlock() + return outputs.sendStripFrameLocked(ctx, rgb) +} + +// A deliberate full-frame diagnostic owns its exact length, including when the +// previous animation used a different count. Continuous streams configure once. +func (outputs *OutputScheduler) sendConfiguredStripFrame(ctx context.Context, rgb []byte) error { + if _, err := native.StripFramePayloads(rgb); err != nil { + return err + } + configuration, err := native.StripConfigurePayload(len(rgb) / 3) if err != nil { return err } outputs.stripMu.Lock() defer outputs.stripMu.Unlock() + if err := outputs.sendStrip(ctx, configuration); err != nil { + return err + } + return outputs.sendStripFrameLocked(ctx, rgb) +} + +func (outputs *OutputScheduler) sendStripFrameLocked(ctx context.Context, rgb []byte) error { + frames, err := native.StripFramePayloads(rgb) + if err != nil { + return err + } for _, payload := range frames { if err := ctx.Err(); err != nil { return err diff --git a/Tools/Controller/internal/control/strip_test.go b/Tools/Controller/internal/control/strip_test.go index 5e6fb902..eaca5f60 100644 --- a/Tools/Controller/internal/control/strip_test.go +++ b/Tools/Controller/internal/control/strip_test.go @@ -57,6 +57,31 @@ func TestStripFailureDoesNotCommitPartialFrame(t *testing.T) { } } +func TestManualStripFrameConfiguresExactLengthBeforeDisplay(t *testing.T) { + target := &recordingOutputTarget{} + outputs := NewOutputScheduler(target) + defer outputs.Close() + if _, err := stripStreamCommand(context.Background(), outputs, []string{"frame", "000000ff0000000000"}); err != nil { + t.Fatal(err) + } + commands := target.snapshotCommands() + if len(commands) != 3 || string(commands[0].payload) != string([]byte{0xFE, 3}) || commands[1].payload[0] != 0xFD || commands[2].payload[0] != 0xFC { + t.Fatalf("configuration/staging/show order = %#v", commands) + } +} + +func TestManualStripFrameDoesNotStageAfterFailedConfiguration(t *testing.T) { + target := &recordingOutputTarget{failAt: 1} + outputs := NewOutputScheduler(target) + defer outputs.Close() + if err := outputs.sendConfiguredStripFrame(context.Background(), []byte{255, 0, 0}); err == nil { + t.Fatal("expected configuration failure") + } + if len(target.snapshotCommands()) != 1 { + t.Fatal("staged after uncertain configuration") + } +} + func TestStripRainbowStopsWithoutMoreFrames(t *testing.T) { target := &recordingOutputTarget{} outputs := NewOutputScheduler(target) diff --git a/docs/STRIP-WORKSPACE-INTEGRATION.md b/docs/STRIP-WORKSPACE-INTEGRATION.md new file mode 100644 index 00000000..ee33682d --- /dev/null +++ b/docs/STRIP-WORKSPACE-INTEGRATION.md @@ -0,0 +1,37 @@ +# Strip workspace integration + +The Flutter effect dialog now supports typed strip programs, the three original +presets, Fade/Pulse envelopes and an offline pixel preview. Unapplied technical +JSON disables typed editing; invalid programs prevent saving. Strip preview uses +the current `effect play ID COUNT FPS` command, matching native Pealayer. + +Manual diagnostics adapt PakarController's deliberate full-frame probe contract +to a linear 1–100-pixel strip. Opening, selecting and moving the cursor never +publishes. Each lit probe consumes one arm bound to the selected peer; the arm +expires after 30 seconds. Connection changes revoke it. Off and each subsequent +probe are separate explicit requests; no failed/uncertain mutation is replayed. +The complete frame zeros every unselected pixel within its selected count. + +`strip frame` now configures the exact frame length before staging/show, under +the same strip lock. A failed configuration cannot stage or display the frame. +Continuous animations retain their configure-once path and existing pacing. +This host change must be deployed with the diagnostics clients. + +PCController's current AVR contract does not expose RGB frame readback. These +controls therefore report command acknowledgement, not PakarController-style +board RGB observations or optical verification. Unknown physical output remains +unknown until the operator checks it; source tests do not establish that result. + +Validation: targeted Go manual-frame and streaming regressions; Flutter preset, +envelope, bounds, full-frame clearing and unapplied-draft fixtures. Runtime and +Android package acceptance remain open until an exact candidate is deployed and +tested. Native Pealayer owns the corresponding typed designer and probe UI in +its strip-workspace work. Playback-clock correction is coordinated separately. + +The live 15-second seat test submitted 150 Up requests through Pealayer at 10 Hz. +No framing/CRC/reset delta was observed; final telemetry reported both directions +Stop and all relays off. Identical Up requests coalesce while already moving, so +this does not imply 150 physical relay cycles. Human motion acceptance is pending. +The three existing strip definitions retain their stable IDs and are grouped +with Fade/Pulse in Strip demos with three-pixel defaults for the current bench. +Private settings backups and live logs are not included in this repository. diff --git a/mobile/GO-FLUTTER-PARITY.md b/mobile/GO-FLUTTER-PARITY.md index d3742a93..29cfd164 100644 --- a/mobile/GO-FLUTTER-PARITY.md +++ b/mobile/GO-FLUTTER-PARITY.md @@ -94,3 +94,9 @@ matches. Locked video activity preserved, controller launch/live proof open. Phone remains 0.1.2/code 3 with 491,588 KiB free; no repeated failed install. Exact device observations: android-0.1.12-delivery.json. Source branches may be reconciled separately; the APK identity remains tied to the exact build source. + +Strip effects now use the current `effect play ID COUNT FPS` command from the +native workbench. The shared effect dialog includes typed strip programs and +Fade/Pulse envelope presets. Deliberate full-frame probes use `strip frame` with +peer fencing and no mutation replay. Current AVR firmware provides no RGB frame +readback; acknowledgement and physical observation remain distinct. diff --git a/mobile/UI-REQUIREMENT-LEDGER.md b/mobile/UI-REQUIREMENT-LEDGER.md index 67a94806..031cd436 100644 --- a/mobile/UI-REQUIREMENT-LEDGER.md +++ b/mobile/UI-REQUIREMENT-LEDGER.md @@ -39,3 +39,13 @@ Final delivery observed 2026-10-13T18:20:30.258795+00:00: 0.1.17/code18 Android Swipe source verification completed: final analyzer clean; one opt-in renderer produced nine phone/tablet/theme/editor fixtures and the phone Live frame was inspected. Gesture behavior is established by the 32 focused checks rather than static screenshots. No native backend/runtime, device installer, output or firmware action. Swipe delivery observed 2026-10-13T18:38:00.154038+00:00: 0.1.18/code19 Android release from a603ecaa216760aba8c8482ce3e897bb0aea1255, mobile tree 04923307280f10a21fec4df1ca4b6c8228af1920; 20,332,384 bytes, SHA256 61c1046709ced11edcb96f6554d377513688189d42f1e481c9bcbe31f540dc47. Package/inherited signer verified. SSH localhost:5022 SM-S908E received /storage/emulated/0/Download/PCController-0.1.18.apk, with exact final hash/size verified after temporary-file promotion; 2,321,252 KiB free afterward. Ready for manual installation; no installer or controller launch. ADB and Android mDNS inventories empty, so no reachable ADB tablet update. PR #673 contains the swipe topic stacked on #671. Source/build/transfer are distinct from actual Android touch, installation and human acceptance; no native runtime replacement or board action. + +## Strip workspace checkpoint +Typed strip authoring, offline pixel preview, five presets (Police, Thunder, +Converging, Fade, Pulse), and explicit peer-bound one-shot pixel probes now have +source implementations. Technical drafts remain authoritative until applied. +Manual RGB frames configure their exact count before stage/show; stream pacing +is unchanged. Source tests are separate from installed APK, visual, physical +and audio acceptance. Board RGB readback is not advertised by the current AVR +contract; diagnostic receipts mean host acknowledgement only. See +../docs/STRIP-WORKSPACE-INTEGRATION.md for remaining gates. diff --git a/mobile/lib/unified/effect_editor.dart b/mobile/lib/unified/effect_editor.dart index 256fa7e9..1a55aaa0 100644 --- a/mobile/lib/unified/effect_editor.dart +++ b/mobile/lib/unified/effect_editor.dart @@ -7,6 +7,7 @@ import 'package:flutter/material.dart'; import 'transport.dart'; import 'sequence_timeline.dart'; import 'controller_dialog.dart'; +import 'strip_designer.dart'; class EffectEditor extends StatefulWidget { const EffectEditor({ @@ -375,6 +376,7 @@ class _EffectEditorState extends State { ), ], ), + if (effect['kind'] == 'strip-stream') StripDesigner(effect: effect, enabled: typedEditing, changed: (value) => setState(() { effect = value; syncRaw(); })), if (effect['kind'] == 'sequence') ...[ const SizedBox(height: 20), SequenceTimeline( @@ -690,6 +692,13 @@ int effectSpanUs(List steps) => steps.fold(0, (span, step) { }); String? effectDefinitionError(Json effect) { + if (effect['kind'] == 'strip-stream') { + for(final entry in {'default_pixels':100,'default_fps':30,'duration_ms':3600000}.entries) { + final v=effect[entry.key]; + if(v is! int || v < (entry.key=='duration_ms'?100:1) || v > entry.value)return 'Invalid ${entry.key.replaceAll('_',' ')}.'; + } + return stripProgramError(object(effect['program'])); + } if (effect['steps'] != null && (effect['steps'] is! List || (effect['steps'] as List).any((value) => value is! Map))) { diff --git a/mobile/lib/unified/go_tools.dart b/mobile/lib/unified/go_tools.dart index cbf1fb31..b64e90f7 100644 --- a/mobile/lib/unified/go_tools.dart +++ b/mobile/lib/unified/go_tools.dart @@ -9,6 +9,7 @@ import 'endpoint_dialog.dart' show AddressCharacterFormatter; import 'go_commands.dart'; import 'preferences.dart'; import 'transport.dart'; +import 'pixel_diagnostic.dart'; class GoToolsPage extends StatefulWidget { const GoToolsPage({ @@ -568,6 +569,7 @@ class _GoToolsPageState extends State { }[mode] ?? mode, }, (v) => stripMode = v), + if (modes.contains('frame')) ExpansionTile(title: const Text('Pixel diagnostics'), children: [PixelDiagnostic(client: client, count: pixels.clamp(1,maxPixels))]), const SizedBox(height: 12), pair( number( @@ -631,7 +633,7 @@ class _GoToolsPageState extends State { 'strip pixel $pixel ${rgb!.join(' ')} $brightness', 'rainbow' => 'strip rainbow $pixels $fps', 'effect' => - 'strip effect play ${nativeArgument(stripEffect!)} $pixels $fps', + 'effect play ${nativeArgument(stripEffect!)} $pixels $fps', _ => 'strip frame ${frame.text}', }; unawaited(run(value)); diff --git a/mobile/lib/unified/pixel_diagnostic.dart b/mobile/lib/unified/pixel_diagnostic.dart new file mode 100644 index 00000000..8f964557 --- /dev/null +++ b/mobile/lib/unified/pixel_diagnostic.dart @@ -0,0 +1,49 @@ +import 'dart:async'; +import 'package:flutter/material.dart'; +import 'go_commands.dart'; + +/// PakarController's deliberate, peer-bound probe adapted to a linear strip. +/// These bytes are requested output, not optical or board RGB readback. +List pixelProbe(int count,int index,List rgb) { + if(count<1||count>100||index<0||index>=count||rgb.length!=3||rgb.any((v)=>v<0||v>255))throw ArgumentError('Invalid pixel probe'); + final bytes=List.filled(count*3,0);bytes.setRange(index*3,index*3+3,rgb);return bytes; +} + +class PixelDiagnostic extends StatefulWidget { + const PixelDiagnostic({super.key,required this.client,required this.count}); + final GoCommandClient client;final int count; + @override State createState()=>_PixelDiagnosticState(); +} +class _PixelDiagnosticState extends State { + int index=0;String? armedOwner,receipt;bool pending=false;Timer? expiration; + bool get ready=>widget.client.current&&widget.client.session.boardReady&&!widget.client.busy&&!pending; + void disarm(){expiration?.cancel();armedOwner=null;} + @override void dispose(){expiration?.cancel();super.dispose();} + @override void didUpdateWidget(covariant PixelDiagnostic old){super.didUpdateWidget(old);if(old.count!=widget.count||old.client!=widget.client){disarm();index=index.clamp(0,widget.count-1);receipt=null;}} + Future send(List rgb,{bool off=false})async { + if(!ready||!off&&armedOwner!=widget.client.session.targetStamp)return; + disarm();setState((){pending=true;receipt=null;}); + try { + final bytes=off?List.filled(widget.count*3,0):pixelProbe(widget.count,index,rgb); + final hex=bytes.map((v)=>v.toRadixString(16).padLeft(2,'0')).join(); + await widget.client.run('strip frame $hex'); + if(mounted&&widget.client.current)setState(()=>receipt=off?'All-off frame acknowledged':'Pixel ${index+1} frame acknowledged'); + }catch(e){if(mounted)setState(()=>receipt='$e');} + finally{if(mounted)setState(()=>pending=false);} + } + @override Widget build(BuildContext context){ + if(!widget.client.current||!widget.client.session.boardReady||armedOwner!=null&&armedOwner!=widget.client.session.targetStamp)disarm(); + return Column(crossAxisAlignment:CrossAxisAlignment.start,children:[ + Text('Pixel diagnostics',style:Theme.of(context).textTheme.titleMedium), + Wrap(spacing:4,runSpacing:4,children:[for(var i=0;isetState(()=>index=i))]), + Row(children:[IconButton(tooltip:'Previous pixel',onPressed:!pending&&index>0?()=>setState(()=>index--):null,icon:const Icon(Icons.chevron_left)),Text('Pixel ${index+1} / ${widget.count}'),IconButton(tooltip:'Next pixel',onPressed:!pending&&index+1setState(()=>index++):null,icon:const Icon(Icons.chevron_right))]), + CheckboxListTile(contentPadding:EdgeInsets.zero,title:const Text('Arm one test'),value:armedOwner!=null,onChanged:ready?(value)=>setState((){disarm();if(value==true){armedOwner=widget.client.session.targetStamp;expiration=Timer(const Duration(seconds:30),(){if(mounted)setState(disarm);});}}):null), + Wrap(spacing:8,runSpacing:8,children:[ + for(final (name,rgb) in [('Red',[255,0,0]),('Green',[0,255,0]),('Blue',[0,0,255]),('White',[255,255,255])])Tooltip(message:'Light this pixel and clear all others',child:FilledButton.tonal(onPressed:ready&&armedOwner!=null?()=>unawaited(send(rgb)):null,child:Text(name))), + OutlinedButton(onPressed:ready?()=>unawaited(send([0,0,0],off:true)):null,child:const Text('All off')), + ]), + SizedBox(height:6,child:pending?const LinearProgressIndicator():null), + if(receipt!=null)SelectableText(receipt!), + ]); + } +} diff --git a/mobile/lib/unified/strip_designer.dart b/mobile/lib/unified/strip_designer.dart new file mode 100644 index 00000000..ca45be0f --- /dev/null +++ b/mobile/lib/unified/strip_designer.dart @@ -0,0 +1,93 @@ +import 'dart:math' as math; +import 'package:flutter/material.dart'; +import 'transport.dart'; + +const stripPresetNames = ['Police red / blue', 'White thunder', 'Converging red dots', 'Fade', 'Pulse']; +Json stripPreset(int index) { + final primary = {'red': 255, 'green': 0, 'blue': 0}; + if (index == 0) return {'primitive':'alternating-zones','primary':primary,'secondary':{'red':0,'green':0,'blue':255},'period_ms':800,'step_ms':100,'swap_after_steps':4,'dim_intensity':36}; + if (index == 2) return {'primitive':'converging-points','primary':primary,'period_ms':2000,'tail_pixels':3}; + final points = index == 1 ? [[0,255],[45,24],[90,220],[145,52],[220,150],[360,0]] : index == 3 ? [[0,0],[1400,255],[2800,0]] : [[0,0],[500,255],[1000,0]]; + return {'primitive':'envelope','primary':index==1?{'red':255,'green':255,'blue':255}:{'red':0,'green':96,'blue':255},'period_ms':index==1?1600:3000,'envelope':[for(final p in points){'at_ms':p[0],'intensity':p[1]}]}; +} + +String? stripProgramError(Json p) { + bool bounded(dynamic v,int low,int high)=>v is int && v>=low && v<=high; + final period=p['period_ms']; + if(!bounded(period,50,3600000)) return 'Cycle must be 50–3600000 ms.'; + for(final key in ['primary','secondary']) { + if(p[key]!=null && (p[key] is! Map || ['red','green','blue'].any((c)=>!bounded(p[key][c],0,255)))) return 'Color channels must be 0–255.'; + } + switch(p['primitive']) { + case 'alternating-zones': + if(!bounded(p['step_ms'],10,period)||!bounded(p['swap_after_steps'],1,1024)||p['dim_intensity']!=null&&!bounded(p['dim_intensity'],0,255)) return 'Invalid flash timing or brightness.'; + case 'converging-points': + if(p['tail_pixels']!=null&&!bounded(p['tail_pixels'],0,100))return 'Tail must be 0–100 pixels.'; + case 'envelope': + final points=p['envelope']; + if(points is! List || points.length<2 || points.length>256)return 'Envelope needs 2–256 points.'; + var previous=-1; + for(final point in points){if(point is! Map || !bounded(point['at_ms'],previous+1,period-1)||!bounded(point['intensity'],0,255))return 'Envelope times must increase inside the cycle.';previous=point['at_ms'];} + default:return 'Choose a supported strip pattern.'; + } + return null; +} + +List stripPreview(Json p,int count,int at) { + if(stripProgramError(p)!=null||count<1||count>100)return []; + final period=p['period_ms'] as int,time=at%(p['period_ms'] as int); + Color color(String key,int intensity){final c=object(p[key]);int channel(String k)=>((c[k] as int? ?? 0)*intensity~/255).clamp(0,255);return Color.fromARGB(255,channel('red'),channel('green'),channel('blue'));} + return List.generate(count,(pixel){ + if(p['primitive']=='alternating-zones'){final step=p['step_ms'] as int;final phase=(time~/step)%math.max(2,period~/step);return color((pixel<(count+1)~/2)==(phase<(p['swap_after_steps'] as int))?'primary':'secondary',phase.isEven?255:(p['dim_intensity'] as int? ?? 0));} + if(p['primitive']=='envelope'){final points=objects(p['envelope']);var level=0;for(var i=0;i0){final previous=points[i-1],start=previous['at_ms'] as int,a=previous['intensity'] as int,b=points[i]['intensity'] as int;level=a+(b-a)*(time-start)~/(end-start);}break;}level=points[i]['intensity'] as int;}return color('primary',level);} + final head=time*((count-1)~/2+1)~/period; + final configured=p['tail_pixels'] as int? ?? 0,tail=math.max(3,configured==0?count~/12:configured); + int intensity(int d)=>d<=tail?255*(tail+1-d)~/(tail+1):0; + return color('primary',math.max(pixel<=head?intensity(head-pixel):0,pixel>=count-1-head?intensity(pixel-(count-1-head)):0)); + }); +} + +class StripDesigner extends StatefulWidget { + const StripDesigner({super.key,required this.effect,required this.enabled,required this.changed}); + final Json effect; final bool enabled; final ValueChanged changed; + @override State createState()=>_StripDesignerState(); +} +class _StripDesignerState extends State { + double phase=0; + void program(Json p)=>widget.changed({...widget.effect,'program':p}); + Widget slider(String title,int value,int low,int high,ValueChanged change)=>Column(crossAxisAlignment:CrossAxisAlignment.start,children:[Text('$title · $value'),Slider(value:value.clamp(low,high).toDouble(),min:low.toDouble(),max:high.toDouble(),onChanged:widget.enabled&&lowchange(v.round()):null)]); + @override Widget build(BuildContext context){ + final p=object(widget.effect['program']),error=stripProgramError(p),count=(widget.effect['default_pixels'] as int? ?? 3).clamp(1,100); + final points=objects(p['envelope']); + return Column(crossAxisAlignment:CrossAxisAlignment.start,children:[ + const SizedBox(height:16),Text('Strip pattern',style:Theme.of(context).textTheme.titleMedium), + Wrap(spacing:8,runSpacing:8,children:[for(var i=0;iprogram(stripPreset(i)):null)]), + if(error!=null)Padding(padding:const EdgeInsets.symmetric(vertical:12),child:Text(error,style:TextStyle(color:Theme.of(context).colorScheme.error))), + if(error==null)...[ + const SizedBox(height:12), + slider('Pixels',count,1,100,(v)=>widget.changed({...widget.effect,'default_pixels':v})), + slider('FPS',widget.effect['default_fps'] as int? ?? 20,1,30,(v)=>widget.changed({...widget.effect,'default_fps':v})), + slider('Cycle (ms)',p['period_ms'],math.max(50,math.max(points.isEmpty?0:(points.last['at_ms'] as int)+1,p['step_ms'] as int? ?? 0)),3600000,(v)=>program({...p,'period_ms':v})), + for(final key in ['primary',if(p['primitive']=='alternating-zones')'secondary'])...[ + Text(key=='primary'?'Color':'Secondary color',style:Theme.of(context).textTheme.titleSmall), + for(final channel in ['red','green','blue'])slider(channel,object(p[key])[channel] as int? ?? 0,0,255,(v)=>program({...p,key:{...object(p[key]),channel:v}})), + ], + if(p['primitive']=='alternating-zones')...[ + slider('Flash interval (ms)',p['step_ms'],10,p['period_ms'],(v)=>program({...p,'step_ms':v})), + slider('Swap after flashes',p['swap_after_steps'],1,1024,(v)=>program({...p,'swap_after_steps':v})), + slider('Dim brightness',p['dim_intensity'] as int? ?? 0,0,255,(v)=>program({...p,'dim_intensity':v})), + ], + if(p['primitive']=='converging-points')slider('Tail pixels',p['tail_pixels'] as int? ?? 0,0,100,(v)=>program({...p,'tail_pixels':v})), + if(p['primitive']=='envelope')...[ + const Text('Brightness envelope'), + for(var i=0;iprogram({...p,'envelope':[for(var j=0;jprogram({...p,'envelope':[for(var j=0;jsetState(()=>phase=v)), + Wrap(spacing:4,runSpacing:4,children:[for(final (i,c) in stripPreview(p,count,(phase*(p['period_ms'] as int)-1).clamp(0,3600000).toInt()).indexed)Tooltip(message:'Pixel ${i+1}',child:Container(width:22,height:22,decoration:BoxDecoration(color:c,borderRadius:BorderRadius.circular(4),border:Border.all(color:Theme.of(context).dividerColor))))]), + ], + ]); + } +} diff --git a/mobile/test/strip_designer_test.dart b/mobile/test/strip_designer_test.dart new file mode 100644 index 00000000..981a5b05 --- /dev/null +++ b/mobile/test/strip_designer_test.dart @@ -0,0 +1,16 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:pccontroller_mobile/unified/strip_designer.dart'; +import 'package:pccontroller_mobile/unified/pixel_diagnostic.dart'; + +void main(){ + test('all presets support three physical pixels',(){for(var i=0;i<5;i++){final p=stripPreset(i);expect(stripProgramError(p),isNull);expect(stripPreview(p,3,1000),hasLength(3));}}); + test('invalid envelope is retained and cannot preview',(){final p=stripPreset(3);p['envelope'][1]['at_ms']=0;expect(stripProgramError(p),isNotNull);expect(stripPreview(p,3,0),isEmpty);expect(p['envelope'][1]['at_ms'],0);}); + test('fade and pulse timing differ',(){expect(stripPreview(stripPreset(3),3,500),isNot(stripPreview(stripPreset(4),3,500)));}); + test('probe clears every unselected pixel and rejects invalid bounds',(){final p=pixelProbe(38,37,[1,2,3]);expect(p,hasLength(114));expect(p.take(111).every((v)=>v==0),isTrue);expect(p.skip(111),[1,2,3]);expect(()=>pixelProbe(3,3,[255,0,0]),throwsArgumentError);expect(()=>pixelProbe(101,0,[255,0,0]),throwsArgumentError);}); + testWidgets('unapplied technical draft disables preset replacement',(tester)async{ + var changed=false; + await tester.pumpWidget(MaterialApp(home:Scaffold(body:SingleChildScrollView(child:StripDesigner(effect:{'program':stripPreset(0),'default_pixels':3,'default_fps':20},enabled:false,changed:(_)=>changed=true))))); + await tester.tap(find.text('Fade'));expect(changed,isFalse); + }); +} From 0e0133d039aa136e9653fccfaf80aa8fcf6b82be Mon Sep 17 00:00:00 2001 From: DRSDavidSoft <4673812+DRSDavidSoft@users.noreply.github.com> Date: Tue, 27 Oct 2026 00:32:40 +0330 Subject: [PATCH 02/19] fix: compare numeric strip preview phase without integer-only getter --- mobile/lib/unified/strip_designer.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mobile/lib/unified/strip_designer.dart b/mobile/lib/unified/strip_designer.dart index ca45be0f..b6ebaa22 100644 --- a/mobile/lib/unified/strip_designer.dart +++ b/mobile/lib/unified/strip_designer.dart @@ -38,7 +38,7 @@ List stripPreview(Json p,int count,int at) { final period=p['period_ms'] as int,time=at%(p['period_ms'] as int); Color color(String key,int intensity){final c=object(p[key]);int channel(String k)=>((c[k] as int? ?? 0)*intensity~/255).clamp(0,255);return Color.fromARGB(255,channel('red'),channel('green'),channel('blue'));} return List.generate(count,(pixel){ - if(p['primitive']=='alternating-zones'){final step=p['step_ms'] as int;final phase=(time~/step)%math.max(2,period~/step);return color((pixel<(count+1)~/2)==(phase<(p['swap_after_steps'] as int))?'primary':'secondary',phase.isEven?255:(p['dim_intensity'] as int? ?? 0));} + if(p['primitive']=='alternating-zones'){final step=p['step_ms'] as int;final phase=(time~/step)%math.max(2,period~/step);return color((pixel<(count+1)~/2)==(phase<(p['swap_after_steps'] as int))?'primary':'secondary',phase % 2 == 0 ? 255 :(p['dim_intensity'] as int? ?? 0));} if(p['primitive']=='envelope'){final points=objects(p['envelope']);var level=0;for(var i=0;i0){final previous=points[i-1],start=previous['at_ms'] as int,a=previous['intensity'] as int,b=points[i]['intensity'] as int;level=a+(b-a)*(time-start)~/(end-start);}break;}level=points[i]['intensity'] as int;}return color('primary',level);} final head=time*((count-1)~/2+1)~/period; final configured=p['tail_pixels'] as int? ?? 0,tail=math.max(3,configured==0?count~/12:configured); From 2c4fe6492e353292bcafdcaf2b983bae0dafa6e7 Mon Sep 17 00:00:00 2001 From: DRSDavidSoft <4673812+DRSDavidSoft@users.noreply.github.com> Date: Tue, 27 Oct 2026 00:35:34 +0330 Subject: [PATCH 03/19] feat: expose strip lighting creation from the effect library --- mobile/lib/main.dart | 22 +++++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/mobile/lib/main.dart b/mobile/lib/main.dart index 66f8838f..3a58c8de 100644 --- a/mobile/lib/main.dart +++ b/mobile/lib/main.dart @@ -20,6 +20,7 @@ import 'unified/relay_style.dart'; import 'pakar_reference.dart' show SevenSegmentPreview; import 'unified/transport.dart'; import 'unified/effect_editor.dart'; +import 'unified/strip_designer.dart'; import 'unified/live_widgets.dart'; import 'unified/automation.dart'; import 'unified/preferences.dart'; @@ -3712,16 +3713,27 @@ class _ControllerHomeState extends State : null, ), ], - trailing: IconButton( + trailing: PopupMenuButton( tooltip: 'New effect', icon: const Icon(Icons.add), - onPressed: () => unawaited( + itemBuilder: (_) => const [ + PopupMenuItem(value: 'sequence', child: Text('Sequence')), + PopupMenuItem(value: 'strip-stream', child: Text('Strip lighting')), + ], + onSelected: (kind) => unawaited( editEffect({ 'id': '', 'name': '', - 'category': 'Effects', - 'kind': 'sequence', - 'steps': [], + 'category': kind == 'sequence' ? 'Effects' : 'Lighting', + 'kind': kind, + if (kind == 'sequence') 'steps': [], + if (kind == 'strip-stream') ...{ + 'program': stripPreset(3), + 'default_pixels': 1, + 'default_fps': 20, + 'duration_ms': 5000, + 'engine': 'host-stream', + }, }), ), ), From 65f4584b52efd25ee94b0e08e25c166fb88a084f Mon Sep 17 00:00:00 2001 From: DRSDavidSoft <4673812+DRSDavidSoft@users.noreply.github.com> Date: Tue, 27 Oct 2026 00:39:27 +0330 Subject: [PATCH 04/19] test: expect supported strip effect command --- mobile/test/go_tools_test.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mobile/test/go_tools_test.dart b/mobile/test/go_tools_test.dart index 17cd813b..de682340 100644 --- a/mobile/test/go_tools_test.dart +++ b/mobile/test/go_tools_test.dart @@ -121,7 +121,7 @@ void main() { await settle(tester); expect( object(calls.last['params'])['command'], - 'strip effect play "warm-pulse" 100 20', + 'effect play "warm-pulse" 100 20', ); await tester.enterText(find.byKey(const ValueKey('Pixels')), '50'); await tester.pump(); From 1275716444bce18bc0a8e3fdce03ba98cf855f16 Mon Sep 17 00:00:00 2001 From: DRSDavidSoft <4673812+DRSDavidSoft@users.noreply.github.com> Date: Tue, 27 Oct 2026 00:41:57 +0330 Subject: [PATCH 05/19] fix: align Flutter flash preview with host phase --- mobile/lib/unified/strip_designer.dart | 2 +- mobile/test/strip_designer_test.dart | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/mobile/lib/unified/strip_designer.dart b/mobile/lib/unified/strip_designer.dart index b6ebaa22..a548bed9 100644 --- a/mobile/lib/unified/strip_designer.dart +++ b/mobile/lib/unified/strip_designer.dart @@ -38,7 +38,7 @@ List stripPreview(Json p,int count,int at) { final period=p['period_ms'] as int,time=at%(p['period_ms'] as int); Color color(String key,int intensity){final c=object(p[key]);int channel(String k)=>((c[k] as int? ?? 0)*intensity~/255).clamp(0,255);return Color.fromARGB(255,channel('red'),channel('green'),channel('blue'));} return List.generate(count,(pixel){ - if(p['primitive']=='alternating-zones'){final step=p['step_ms'] as int;final phase=(time~/step)%math.max(2,period~/step);return color((pixel<(count+1)~/2)==(phase<(p['swap_after_steps'] as int))?'primary':'secondary',phase % 2 == 0 ? 255 :(p['dim_intensity'] as int? ?? 0));} + if(p['primitive']=='alternating-zones'){final step=p['step_ms'] as int;final phase=(at~/step)%math.max(2,period~/step);return color((pixel<(count+1)~/2)==(phase<(p['swap_after_steps'] as int))?'primary':'secondary',phase % 2 == 0 ? 255 :(p['dim_intensity'] as int? ?? 0));} if(p['primitive']=='envelope'){final points=objects(p['envelope']);var level=0;for(var i=0;i0){final previous=points[i-1],start=previous['at_ms'] as int,a=previous['intensity'] as int,b=points[i]['intensity'] as int;level=a+(b-a)*(time-start)~/(end-start);}break;}level=points[i]['intensity'] as int;}return color('primary',level);} final head=time*((count-1)~/2+1)~/period; final configured=p['tail_pixels'] as int? ?? 0,tail=math.max(3,configured==0?count~/12:configured); diff --git a/mobile/test/strip_designer_test.dart b/mobile/test/strip_designer_test.dart index 981a5b05..606d5d07 100644 --- a/mobile/test/strip_designer_test.dart +++ b/mobile/test/strip_designer_test.dart @@ -4,6 +4,10 @@ import 'package:pccontroller_mobile/unified/strip_designer.dart'; import 'package:pccontroller_mobile/unified/pixel_diagnostic.dart'; void main(){ + test('flash preview follows host step cycle for indivisible periods', () { + final p = stripPreset(0)..['period_ms'] = 850; + expect(stripPreview(p, 3, 900).first, const Color.fromARGB(255, 36, 0, 0)); + }); test('all presets support three physical pixels',(){for(var i=0;i<5;i++){final p=stripPreset(i);expect(stripProgramError(p),isNull);expect(stripPreview(p,3,1000),hasLength(3));}}); test('invalid envelope is retained and cannot preview',(){final p=stripPreset(3);p['envelope'][1]['at_ms']=0;expect(stripProgramError(p),isNotNull);expect(stripPreview(p,3,0),isEmpty);expect(p['envelope'][1]['at_ms'],0);}); test('fade and pulse timing differ',(){expect(stripPreview(stripPreset(3),3,500),isNot(stripPreview(stripPreset(4),3,500)));}); From 83991c5579fd2988e774850e74065338dfd11b70 Mon Sep 17 00:00:00 2001 From: DRSDavidSoft <4673812+DRSDavidSoft@users.noreply.github.com> Date: Tue, 27 Oct 2026 01:35:49 +0330 Subject: [PATCH 06/19] fix: fence PWM readback during prepared playback --- .../internal/control/media_playback.go | 17 ++++ .../internal/control/media_timeline.go | 38 ++++++-- .../internal/control/media_timeline_test.go | 88 +++++++++++++++++++ Tools/Controller/internal/control/runtime.go | 8 ++ .../internal/ipcjson/pwm_channels.go | 7 ++ Tools/Controller/internal/link/session.go | 13 +++ docs/STRIP-WORKSPACE-INTEGRATION.md | 25 ++++++ 7 files changed, 189 insertions(+), 7 deletions(-) diff --git a/Tools/Controller/internal/control/media_playback.go b/Tools/Controller/internal/control/media_playback.go index 6b5cb6b0..b16111ad 100644 --- a/Tools/Controller/internal/control/media_playback.go +++ b/Tools/Controller/internal/control/media_playback.go @@ -155,6 +155,23 @@ func (runtime *Runtime) UpdateMediaPlayback(value MediaPlaybackUpdate) (MediaPla state.mu.Unlock() return runtime.MediaPlayback(), errors.New("stale media playback sequence") } + if value.Playing && value.PlanRevision != 0 && !previous.Playing { + // Fence queued/new PWM reads before checking the non-preemptible one. + // Never acknowledge Play while a previously sent read owns the slot. + runtime.mediaTimeline.readbackReserved.Store(true) + runtime.mu.RLock() + session := runtime.session + runtime.mu.RUnlock() + if session != nil && session.HasPendingRequest(native.OpPWMGet) { + runtime.mediaTimeline.readbackReserved.Store(false) + state.mu.Unlock() + return runtime.MediaPlayback(), &ResourceBusyError{Resource: "serial_readback", Owner: "pwm_readback", + Message: "PWM readback is still in flight; playback was not started"} + } + } + if !value.Playing { + runtime.mediaTimeline.readbackReserved.Store(false) + } received := time.Now() clockTiming := mediaClockTiming(previous, value, state.received, received) state.received = received diff --git a/Tools/Controller/internal/control/media_timeline.go b/Tools/Controller/internal/control/media_timeline.go index 53826e95..ff067060 100644 --- a/Tools/Controller/internal/control/media_timeline.go +++ b/Tools/Controller/internal/control/media_timeline.go @@ -13,6 +13,7 @@ import ( "sort" "strings" "sync" + "sync/atomic" "time" "pccontroller.local/controller/internal/appconfig" @@ -99,16 +100,38 @@ type mediaTimelineStep struct { payload []byte } type mediaTimelineState struct { - operation sync.Mutex - mu sync.Mutex - status MediaTimelineStatus - plan MediaTimelinePlan - steps []mediaTimelineStep - cancel context.CancelFunc - done chan struct{} + readbackReserved atomic.Bool + operation sync.Mutex + mu sync.Mutex + status MediaTimelineStatus + plan MediaTimelinePlan + steps []mediaTimelineStep + cancel context.CancelFunc + done chan struct{} } type mediaTimelineContextKey struct{} +// PWM observations already arrive as complete changed-only frames. A fresh +// readback must not occupy the wire across a prepared cue's deadline. Check the +// accepted clock as well as worker state, including the resume/pause boundary. +func (runtime *Runtime) rejectTimelinePWMReadback() error { + if runtime.mediaTimeline.readbackReserved.Load() { + return timelinePWMReadbackBusy() + } + plan := runtime.MediaTimeline() + clock, _, _ := runtime.mediaTimelineClock() + if plan.State == "playing" || (plan.StepCount > 0 && clock.Playing && + clock.ClientID == plan.ClientID && clock.PlanRevision == plan.Revision) { + return timelinePWMReadbackBusy() + } + return nil +} + +func timelinePWMReadbackBusy() error { + return &ResourceBusyError{Resource: "serial_readback", Owner: "media_timeline", + Message: "pause prepared playback before requesting fresh PWM readback; use published PWM observations while playing"} +} + func (runtime *Runtime) rejectMediaTimelineConflict(ctx context.Context, opcode byte) error { if ctx.Value(mediaTimelineContextKey{}) != nil { return nil @@ -418,6 +441,7 @@ func (runtime *Runtime) stopMediaTimeline() { state.status.State = "stopped" } state.mu.Unlock() + state.readbackReserved.Store(false) } func (runtime *Runtime) mediaTimelineOff(generation uint64) error { // Pin cleanup to this board too. Never switch off a replacement board. diff --git a/Tools/Controller/internal/control/media_timeline_test.go b/Tools/Controller/internal/control/media_timeline_test.go index 200cf717..ea0b5e9a 100644 --- a/Tools/Controller/internal/control/media_timeline_test.go +++ b/Tools/Controller/internal/control/media_timeline_test.go @@ -1,6 +1,7 @@ package control import ( + "context" "errors" "pccontroller.local/controller/internal/appconfig" "pccontroller.local/controller/internal/link" @@ -12,6 +13,93 @@ import ( "time" ) +func TestMediaTimelinePWMReadbackCannotBlockPreparedEdges(t *testing.T) { + runtime, frames, mu := mediaTimelineFixture(t, false) + value := armTimeline(t, runtime, 0) + if err := runtime.rejectTimelinePWMReadback(); err != nil { + t.Fatalf("paused readback rejected: %v", err) + } + if _, err := runtime.UpdateMediaPlayback(value); err != nil { + t.Fatal(err) + } + for i := 0; i < 10; i++ { + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + _, err := runtime.Request(ctx, native.OpPWMGet, nil, native.OpPWMValues) + cancel() + var busy *ResourceBusyError + if !errors.As(err, &busy) || busy.Resource != "serial_readback" { + t.Fatalf("fresh readback should report ownership conflict, got %v", err) + } + } + status := waitTimeline(t, runtime, func(status MediaTimelineStatus) bool { return status.Acknowledged == 2 }) + if status.State == "faulted" { + t.Fatal(status.Error) + } + mu.Lock() + for _, frame := range *frames { + if frame.Opcode == native.OpPWMGet { + mu.Unlock() + t.Fatal("PWM poll reached the wire during playback") + } + } + mu.Unlock() + value.Playing = false + value.Sequence++ + if _, err := runtime.UpdateMediaPlayback(value); err != nil { + t.Fatal(err) + } + if err := runtime.rejectTimelinePWMReadback(); err != nil { + t.Fatalf("pause did not release readback: %v", err) + } +} + +func TestMediaTimelineDoesNotAcknowledgePlayWithPWMReadbackInFlight(t *testing.T) { + runtime, frames, mu := mediaTimelineFixture(t, false) + value := armTimeline(t, runtime, 0) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + done := make(chan error, 1) + go func() { + // This fixture ACKs commands, but deliberately does not provide PWM + // values, leaving the read registered until its caller cancels it. + _, err := runtime.Request(ctx, native.OpPWMGet, nil, native.OpPWMValues) + done <- err + }() + runtime.mu.RLock() + session := runtime.session + runtime.mu.RUnlock() + deadline := time.Now().Add(time.Second) + written := false + for !written && time.Now().Before(deadline) { + mu.Lock() + for _, frame := range *frames { + written = written || frame.Opcode == native.OpPWMGet + } + mu.Unlock() + time.Sleep(time.Millisecond) + } + if !written || !session.HasPendingRequest(native.OpPWMGet) { + t.Fatal("PWM read did not acquire the wire") + } + _, err := runtime.UpdateMediaPlayback(value) + var busy *ResourceBusyError + if !errors.As(err, &busy) || busy.Owner != "pwm_readback" { + t.Fatalf("Play falsely accepted an occupied serial slot: %v", err) + } + if runtime.MediaPlayback().Playing || runtime.mediaTimeline.readbackReserved.Load() { + t.Fatal("rejected Play changed the accepted clock or stranded admission") + } + cancel() + select { + case <-done: + case <-time.After(time.Second): + t.Fatal("readback did not release its request slot") + } + if _, err := runtime.UpdateMediaPlayback(value); err != nil { + t.Fatalf("released readback still blocks explicit Play: %v", err) + } +} + func TestMediaTimelineReportsStandaloneStripAsRetryableResourceBusy(t *testing.T) { runtime := New(Options{}) defer runtime.Close() diff --git a/Tools/Controller/internal/control/runtime.go b/Tools/Controller/internal/control/runtime.go index b3f7c8e3..9adc890b 100644 --- a/Tools/Controller/internal/control/runtime.go +++ b/Tools/Controller/internal/control/runtime.go @@ -1962,6 +1962,14 @@ func (runtime *Runtime) requestAtGenerationPriority( return native.Frame{}, err } defer segmentUnlock() + if opcode == native.OpPWMGet { + if err := runtime.rejectTimelinePWMReadback(); err != nil { + return native.Frame{}, err + } + // A request admitted while paused may wait behind another transaction. + // Recheck immediately before writing so it cannot leak into playback. + ctx = link.WithRequestWriteGuard(ctx, runtime.rejectTimelinePWMReadback) + } if err := runtime.rejectMediaTimelineConflict(ctx, opcode); err != nil { return native.Frame{}, err } diff --git a/Tools/Controller/internal/ipcjson/pwm_channels.go b/Tools/Controller/internal/ipcjson/pwm_channels.go index c1d314d6..ca4ebf9e 100644 --- a/Tools/Controller/internal/ipcjson/pwm_channels.go +++ b/Tools/Controller/internal/ipcjson/pwm_channels.go @@ -36,6 +36,13 @@ type pwmValues struct { } func (service *Service) pwmValues(ctx context.Context) (pwmValues, error) { + if service.Client.MediaTimeline().State == "playing" { + // Preserve the observation's generation, revision and original time. + // Playback must not create polling traffic or pretend this is a new ACK. + if values := service.cachedPWMValues(); values != nil { + return *values, nil + } + } ctx, cancel := context.WithTimeout(ctx, pwmOperationTimeout) defer cancel() values, err := service.Client.PWMValues(ctx) diff --git a/Tools/Controller/internal/link/session.go b/Tools/Controller/internal/link/session.go index 78e08023..3b14452a 100644 --- a/Tools/Controller/internal/link/session.go +++ b/Tools/Controller/internal/link/session.go @@ -437,6 +437,19 @@ func (s *Session) Request( return s.RequestWithPriority(ctx, RequestPriorityNormal, opcode, payload, expected...) } +// HasPendingRequest reports a registered request, including its response wait. +// Callers must separately fence new admission before using this as readiness. +func (s *Session) HasPendingRequest(opcode byte) bool { + s.stateMu.RLock() + defer s.stateMu.RUnlock() + for _, waiter := range s.waiters { + if waiter.requestOpcode == opcode { + return true + } + } + return false +} + // RequestWithPriority preserves wire serialization while allowing timing-bound // prepared-timeline commands to overtake noncritical requests that are queued // but have not yet reached the MCU. It never preempts an in-flight request. diff --git a/docs/STRIP-WORKSPACE-INTEGRATION.md b/docs/STRIP-WORKSPACE-INTEGRATION.md index ee33682d..a4272e7e 100644 --- a/docs/STRIP-WORKSPACE-INTEGRATION.md +++ b/docs/STRIP-WORKSPACE-INTEGRATION.md @@ -35,3 +35,28 @@ this does not imply 150 physical relay cycles. Human motion acceptance is pendin The three existing strip definitions retain their stable IDs and are grouped with Fade/Pulse in Strip demos with three-pixel defaults for the current bench. Private settings backups and live logs are not included in this repository. + +## Prepared playback and readback contention + +A replay on host ci.1647 exposed a PWM_GET (0x15) response wait occupying the +serial slot for 88 ms. The strip configure cue reached dispatch about 2 ms late, +then exhausted its unchanged 50 ms deadline behind that read. Framing and CRC +counters remained unchanged: this is a separate scheduling conflict. + +Fresh PWM reads now return a structured resource conflict during prepared +playback, with a second check immediately before the wire write. The PWM status +RPC serves the existing observation during playback without changing its time, +generation or revision. Resume fences new reads before checking registered ones; +an already in-flight PWM read rejects Play without accepting a playing clock. +The read is not canceled, and no mutation is retried. Pause releases the fence. + +This deliberately does not preempt an in-flight request. A client must handle +the pre-Play resource conflict honestly; seamless resume under that contention +and the final live synchronized replay remain acceptance gates. The earlier +ProgramState contention observed on ci.1631 is not claimed fixed by this change. + +Deployment evidence must include live compiled version/source identity from +the current listener. A prior updater reported a new disk digest while an old +TUI subsequently took primary ownership. That old process was gracefully closed +with operator approval and the ci.1647 listener verified separately. A disk +artifact manifest alone does not prove which executable image owns the board. From c5ff502ccad9b540351408abcbdaca2196814c56 Mon Sep 17 00:00:00 2001 From: DRSDavidSoft <4673812+DRSDavidSoft@users.noreply.github.com> Date: Tue, 27 Oct 2026 02:05:12 +0330 Subject: [PATCH 07/19] fix: reserve serial channel before prepared playback --- .../internal/control/board_liveness_test.go | 16 +++ .../internal/control/media_playback.go | 56 ++++++--- .../internal/control/media_timeline.go | 62 +++++----- .../internal/control/media_timeline_test.go | 116 +++++++++++++++--- .../internal/control/media_timeline_wire.go | 106 ++++++++++++++++ Tools/Controller/internal/control/runtime.go | 24 ++-- .../internal/ipcjson/pwm_channels.go | 2 +- Tools/Controller/internal/link/session.go | 60 ++++++++- .../link/timeline_reservation_test.go | 97 +++++++++++++++ docs/Prepared-Media-Timeline.md | 26 ++++ docs/STRIP-WORKSPACE-INTEGRATION.md | 26 ++-- 11 files changed, 501 insertions(+), 90 deletions(-) create mode 100644 Tools/Controller/internal/control/media_timeline_wire.go create mode 100644 Tools/Controller/internal/link/timeline_reservation_test.go diff --git a/Tools/Controller/internal/control/board_liveness_test.go b/Tools/Controller/internal/control/board_liveness_test.go index 59661020..b2511cf9 100644 --- a/Tools/Controller/internal/control/board_liveness_test.go +++ b/Tools/Controller/internal/control/board_liveness_test.go @@ -26,3 +26,19 @@ func TestBoardLivenessRequiresConsecutiveFailuresAndSuccessResetsWindow(t *testi } } } + +func TestBoardLivenessReservationIsNotAStatusFailureOrSuccess(t *testing.T) { + tracker := boardLivenessFailureTracker{failures: 1} + for i := 0; i < 10; i++ { + if tracker.observe(&ResourceBusyError{Resource: "serial_channel", Owner: "media_timeline"}) { + t.Fatal("intentional serial reservation triggered DTR recovery") + } + } + if tracker.failures != 1 { + t.Fatal("deferred probe invented a healthy response") + } + tracker.observe(nil) + if tracker.failures != 0 { + t.Fatal("real successful probe did not restore health") + } +} diff --git a/Tools/Controller/internal/control/media_playback.go b/Tools/Controller/internal/control/media_playback.go index b16111ad..5695e3b7 100644 --- a/Tools/Controller/internal/control/media_playback.go +++ b/Tools/Controller/internal/control/media_playback.go @@ -25,6 +25,7 @@ type MediaPlaybackUpdate struct { PositionMS uint64 `json:"position_ms"` DurationMS *uint64 `json:"duration_ms,omitempty"` Playing bool `json:"playing"` + PreparePlay bool `json:"prepare_play"` Loaded bool `json:"loaded"` Rate float64 `json:"rate"` Epoch uint64 `json:"epoch,omitempty"` @@ -90,6 +91,9 @@ func (value MediaPlaybackUpdate) validate() error { if value.Playing && !value.Loaded { return errors.New("unloaded media cannot be playing") } + if value.PreparePlay && (value.Playing || !value.Loaded || value.PlanRevision == 0) { + return errors.New("prepare_play requires paused loaded media and a prepared plan revision") + } return nil } func copyMediaUpdate(value MediaPlaybackUpdate) MediaPlaybackUpdate { @@ -130,9 +134,15 @@ func (runtime *Runtime) UpdateMediaPlayback(value MediaPlaybackUpdate) (MediaPla if plan.ClientID != value.ClientID || plan.Revision != value.PlanRevision { return runtime.MediaPlayback(), errors.New("media clock requires the acknowledged prepared timeline revision") } - if value.Playing && plan.StepCount > 0 && (plan.State == "faulted" || plan.State == "stopped" || plan.ArmedEpoch != value.Epoch || plan.ClockSequence == 0) { + if value.Playing && plan.StepCount > 0 && plan.State == "faulted" { + return runtime.MediaPlayback(), fmt.Errorf("prepared hardware timeline fault: %s", plan.Error) + } + if value.Playing && plan.StepCount > 0 && (plan.State == "stopped" || plan.ArmedEpoch != value.Epoch || plan.ClockSequence == 0) { return runtime.MediaPlayback(), errors.New("hardware timeline is not armed for this media epoch; pause and reprepare") } + if value.Playing && plan.StepCount > 0 && !plan.PlayReady { + return runtime.MediaPlayback(), errors.New("prepare_play must reserve the paused hardware timeline before starting playback") + } } else if value.Playing { plan := runtime.MediaTimeline() if plan.ClientID == value.ClientID && plan.StepCount > 0 && plan.State != "stopped" { @@ -155,22 +165,10 @@ func (runtime *Runtime) UpdateMediaPlayback(value MediaPlaybackUpdate) (MediaPla state.mu.Unlock() return runtime.MediaPlayback(), errors.New("stale media playback sequence") } - if value.Playing && value.PlanRevision != 0 && !previous.Playing { - // Fence queued/new PWM reads before checking the non-preemptible one. - // Never acknowledge Play while a previously sent read owns the slot. - runtime.mediaTimeline.readbackReserved.Store(true) - runtime.mu.RLock() - session := runtime.session - runtime.mu.RUnlock() - if session != nil && session.HasPendingRequest(native.OpPWMGet) { - runtime.mediaTimeline.readbackReserved.Store(false) - state.mu.Unlock() - return runtime.MediaPlayback(), &ResourceBusyError{Resource: "serial_readback", Owner: "pwm_readback", - Message: "PWM readback is still in flight; playback was not started"} - } - } - if !value.Playing { - runtime.mediaTimeline.readbackReserved.Store(false) + if value.Playing { + runtime.mediaTimeline.mu.Lock() + runtime.mediaTimeline.wireRenewedAt = time.Now() + runtime.mediaTimeline.mu.Unlock() } received := time.Now() clockTiming := mediaClockTiming(previous, value, state.received, received) @@ -217,7 +215,7 @@ func (runtime *Runtime) UpdateMediaPlayback(value MediaPlaybackUpdate) (MediaPla plan := runtime.MediaTimeline() if plan.ClientID == value.ClientID && plan.Revision == value.PlanRevision && plan.StepCount > 0 && !(fresh && mediaTimelinePausedHeartbeatArmed(plan, previous.MediaPlaybackUpdate, value)) { - deadline := time.NewTimer(mediaTimelineArmAckTimeout) + deadline := time.NewTimer(time.Until(received.Add(mediaTimelineArmAckTimeout))) poll := time.NewTicker(time.Millisecond) defer deadline.Stop() defer poll.Stop() @@ -240,6 +238,19 @@ func (runtime *Runtime) UpdateMediaPlayback(value MediaPlaybackUpdate) (MediaPla } } } + if !value.Playing { + plan := runtime.MediaTimeline() + if value.PreparePlay && plan.StepCount > 0 { + ctx, cancel := context.WithDeadline(context.Background(), received.Add(mediaTimelineArmAckTimeout)) + err := runtime.reserveMediaTimelineWire(ctx, value) + cancel() + if err != nil { + return runtime.MediaPlayback(), fmt.Errorf("paused serial reservation not ready: %w", err) + } + } else { + runtime.releaseMediaTimelineWire() + } + } return runtime.MediaPlayback(), nil } @@ -331,6 +342,15 @@ func (runtime *Runtime) runMediaPlayback() { _, _ = runtime.SetProgramState("media-playback", mode, "client playback") claimed = playing } + if runtime.MediaTimeline().PlayReady { + // Presentation cannot compete with actuator deadlines. Keep the last + // observed display receipt and label it deferred, never synchronized. + state.mu.Lock() + state.snapshot.BoardSynced = false + state.snapshot.BoardError = "board clock display deferred during prepared playback" + state.mu.Unlock() + continue + } runtime.mu.RLock() connected, generation, capabilities := runtime.session != nil, runtime.generation, runtime.hello.Capabilities runtime.mu.RUnlock() diff --git a/Tools/Controller/internal/control/media_timeline.go b/Tools/Controller/internal/control/media_timeline.go index ff067060..a0c4096d 100644 --- a/Tools/Controller/internal/control/media_timeline.go +++ b/Tools/Controller/internal/control/media_timeline.go @@ -13,7 +13,6 @@ import ( "sort" "strings" "sync" - "sync/atomic" "time" "pccontroller.local/controller/internal/appconfig" @@ -40,6 +39,7 @@ type MediaTimelinePlan struct { MaxLatenessMS uint32 `json:"max_lateness_ms"` } type MediaTimelineStatus struct { + PlayReady bool `json:"play_ready"` ClientID string `json:"client_id"` Revision uint64 `json:"revision"` Hash string `json:"hash,omitempty"` @@ -100,38 +100,18 @@ type mediaTimelineStep struct { payload []byte } type mediaTimelineState struct { - readbackReserved atomic.Bool - operation sync.Mutex - mu sync.Mutex - status MediaTimelineStatus - plan MediaTimelinePlan - steps []mediaTimelineStep - cancel context.CancelFunc - done chan struct{} + releaseWire func() + wireRenewedAt time.Time + operation sync.Mutex + mu sync.Mutex + status MediaTimelineStatus + plan MediaTimelinePlan + steps []mediaTimelineStep + cancel context.CancelFunc + done chan struct{} } type mediaTimelineContextKey struct{} -// PWM observations already arrive as complete changed-only frames. A fresh -// readback must not occupy the wire across a prepared cue's deadline. Check the -// accepted clock as well as worker state, including the resume/pause boundary. -func (runtime *Runtime) rejectTimelinePWMReadback() error { - if runtime.mediaTimeline.readbackReserved.Load() { - return timelinePWMReadbackBusy() - } - plan := runtime.MediaTimeline() - clock, _, _ := runtime.mediaTimelineClock() - if plan.State == "playing" || (plan.StepCount > 0 && clock.Playing && - clock.ClientID == plan.ClientID && clock.PlanRevision == plan.Revision) { - return timelinePWMReadbackBusy() - } - return nil -} - -func timelinePWMReadbackBusy() error { - return &ResourceBusyError{Resource: "serial_readback", Owner: "media_timeline", - Message: "pause prepared playback before requesting fresh PWM readback; use published PWM observations while playing"} -} - func (runtime *Runtime) rejectMediaTimelineConflict(ctx context.Context, opcode byte) error { if ctx.Value(mediaTimelineContextKey{}) != nil { return nil @@ -146,7 +126,11 @@ func (runtime *Runtime) rejectMediaTimelineConflict(ctx context.Context, opcode func (runtime *Runtime) MediaTimeline() MediaTimelineStatus { runtime.mediaTimeline.mu.Lock() defer runtime.mediaTimeline.mu.Unlock() - return runtime.mediaTimeline.status + status := runtime.mediaTimeline.status + if status.State == "paused" && time.Since(runtime.mediaTimeline.wireRenewedAt) > mediaTimelineArmAckTimeout { + status.PlayReady = false + } + return status } func compileMediaTimeline(plan MediaTimelinePlan, runner *MacroRunner) ([]mediaTimelineStep, error) { @@ -441,7 +425,7 @@ func (runtime *Runtime) stopMediaTimeline() { state.status.State = "stopped" } state.mu.Unlock() - state.readbackReserved.Store(false) + runtime.releaseMediaTimelineWire() } func (runtime *Runtime) mediaTimelineOff(generation uint64) error { // Pin cleanup to this board too. Never switch off a replacement board. @@ -513,6 +497,7 @@ func mediaTimelineRestore(steps []mediaTimelineStep, index int) []mediaTimelineS } func (runtime *Runtime) runMediaTimeline(ctx context.Context, done chan struct{}, plan MediaTimelinePlan, steps []mediaTimelineStep, generation uint64) { defer close(done) + defer runtime.releaseMediaTimelineWire() ticker := time.NewTicker(2 * time.Millisecond) defer ticker.Stop() index := 0 @@ -520,6 +505,7 @@ func (runtime *Runtime) runMediaTimeline(ctx context.Context, done chan struct{} wasPlaying := false owned := false lastWake := time.Now() + var lastPresentation time.Time defer func() { if owned { if err := runtime.mediaTimelineOff(generation); err != nil { @@ -570,6 +556,7 @@ func (runtime *Runtime) runMediaTimeline(ctx context.Context, done chan struct{} fault("video clock feedback expired; seek before the pending cue and reprepare") return } + runtime.expirePausedMediaTimelineWire() continue } if runtime.emergencyStop.Load() { @@ -707,5 +694,16 @@ func (runtime *Runtime) runMediaTimeline(ctx context.Context, done chan struct{} index++ position = actual } + // Bound optional maintenance to 50 ms in gaps of at least 250 ms. + // The original cue timestamps and lateness checks remain unchanged. + currentPosition := float64(clock.PositionMS) + time.Since(anchor).Seconds()*1000*clock.Rate + gapMS := math.Inf(1) + if index < len(steps) { + gapMS = (steps[index].dueMS - currentPosition) / clock.Rate + } + if gapMS >= 250 && time.Since(lastPresentation) >= 2*time.Second { + runtime.serviceTimelinePresentation(ctx, clock, anchor, generation) + lastPresentation = time.Now() + } } } diff --git a/Tools/Controller/internal/control/media_timeline_test.go b/Tools/Controller/internal/control/media_timeline_test.go index ea0b5e9a..e50751c2 100644 --- a/Tools/Controller/internal/control/media_timeline_test.go +++ b/Tools/Controller/internal/control/media_timeline_test.go @@ -13,21 +13,21 @@ import ( "time" ) -func TestMediaTimelinePWMReadbackCannotBlockPreparedEdges(t *testing.T) { +func TestMediaTimelineBackgroundTrafficCannotBlockPreparedEdges(t *testing.T) { runtime, frames, mu := mediaTimelineFixture(t, false) value := armTimeline(t, runtime, 0) - if err := runtime.rejectTimelinePWMReadback(); err != nil { - t.Fatalf("paused readback rejected: %v", err) - } if _, err := runtime.UpdateMediaPlayback(value); err != nil { t.Fatal(err) } - for i := 0; i < 10; i++ { + for _, opcode := range []byte{native.OpPWMGet, native.OpGetStatus, native.OpDisplayText, native.OpProgramState} { ctx, cancel := context.WithTimeout(context.Background(), time.Second) - _, err := runtime.Request(ctx, native.OpPWMGet, nil, native.OpPWMValues) + // Internal presentation bypasses the live-output conflict check, but + // still must not bypass the actual wire reservation. + ctx = context.WithValue(ctx, mediaTimelineContextKey{}, true) + _, err := runtime.Request(ctx, opcode, nil, native.OpACK) cancel() var busy *ResourceBusyError - if !errors.As(err, &busy) || busy.Resource != "serial_readback" { + if !errors.As(err, &busy) || busy.Resource != "serial_channel" { t.Fatalf("fresh readback should report ownership conflict, got %v", err) } } @@ -48,14 +48,38 @@ func TestMediaTimelinePWMReadbackCannotBlockPreparedEdges(t *testing.T) { if _, err := runtime.UpdateMediaPlayback(value); err != nil { t.Fatal(err) } - if err := runtime.rejectTimelinePWMReadback(); err != nil { - t.Fatalf("pause did not release readback: %v", err) + if runtime.MediaTimeline().PlayReady { + t.Fatal("pause did not release readback") + } +} + +func TestMediaTimelineReservationLeaseUsesGrantTimeNotOldClockReceipt(t *testing.T) { + runtime, _, _ := mediaTimelineFixture(t, false) + _ = armTimeline(t, runtime, 0) + runtime.mediaPlayback.mu.Lock() + runtime.mediaPlayback.received = time.Now().Add(-300 * time.Millisecond) + runtime.mediaPlayback.mu.Unlock() + runtime.expirePausedMediaTimelineWire() + if !runtime.MediaTimeline().PlayReady { + t.Fatal("old paused clock receipt prematurely expired newly granted reservation") + } + runtime.mediaTimeline.mu.Lock() + runtime.mediaTimeline.wireRenewedAt = time.Now().Add(-300 * time.Millisecond) + runtime.mediaTimeline.mu.Unlock() + runtime.expirePausedMediaTimelineWire() + if runtime.MediaTimeline().PlayReady { + t.Fatal("expired reservation still advertised readiness") } } func TestMediaTimelineDoesNotAcknowledgePlayWithPWMReadbackInFlight(t *testing.T) { runtime, frames, mu := mediaTimelineFixture(t, false) value := armTimeline(t, runtime, 0) + value.Playing = false + if _, err := runtime.UpdateMediaPlayback(value); err != nil { + t.Fatal(err) + } + value.Sequence++ ctx, cancel := context.WithCancel(context.Background()) defer cancel() done := make(chan error, 1) @@ -81,13 +105,16 @@ func TestMediaTimelineDoesNotAcknowledgePlayWithPWMReadbackInFlight(t *testing.T if !written || !session.HasPendingRequest(native.OpPWMGet) { t.Fatal("PWM read did not acquire the wire") } - _, err := runtime.UpdateMediaPlayback(value) - var busy *ResourceBusyError - if !errors.As(err, &busy) || busy.Owner != "pwm_readback" { - t.Fatalf("Play falsely accepted an occupied serial slot: %v", err) + value.PreparePlay = true + armed := make(chan error, 1) + go func() { _, err := runtime.UpdateMediaPlayback(value); armed <- err }() + select { + case err := <-armed: + t.Fatalf("reservation returned before read drained: %v", err) + case <-time.After(20 * time.Millisecond): } - if runtime.MediaPlayback().Playing || runtime.mediaTimeline.readbackReserved.Load() { - t.Fatal("rejected Play changed the accepted clock or stranded admission") + if runtime.MediaPlayback().Playing || runtime.MediaTimeline().PlayReady { + t.Fatal("draining reservation falsely acknowledged readiness or Play") } cancel() select { @@ -95,6 +122,20 @@ func TestMediaTimelineDoesNotAcknowledgePlayWithPWMReadbackInFlight(t *testing.T case <-time.After(time.Second): t.Fatal("readback did not release its request slot") } + select { + case err := <-armed: + if err != nil { + t.Fatal(err) + } + case <-time.After(time.Second): + t.Fatal("reservation did not finish") + } + if !runtime.MediaTimeline().PlayReady { + t.Fatal("drained reservation not acknowledged") + } + value.PreparePlay = false + value.Playing = true + value.Sequence++ if _, err := runtime.UpdateMediaPlayback(value); err != nil { t.Fatalf("released readback still blocks explicit Play: %v", err) } @@ -114,6 +155,43 @@ func TestMediaTimelineReportsStandaloneStripAsRetryableResourceBusy(t *testing.T } } +func TestMediaTimelinePausedReservationExpiresAndRequiresNewPreparePlay(t *testing.T) { + runtime, _, _ := mediaTimelineFixture(t, false) + value := armTimeline(t, runtime, 0) + if !runtime.MediaTimeline().PlayReady { + t.Fatal("paused reservation missing") + } + waitTimeline(t, runtime, func(status MediaTimelineStatus) bool { return !status.PlayReady }) + if _, err := runtime.UpdateMediaPlayback(value); err == nil { + t.Fatal("expired serial reservation accepted Play") + } +} + +func TestMediaTimelineEmergencyStopReleasesPausedReservation(t *testing.T) { + runtime, _, _ := mediaTimelineFixture(t, false) + _ = armTimeline(t, runtime, 0) + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + if _, err := runtime.SetEmergencyStop(ctx, true, "test", "reservation regression"); err != nil { + t.Fatal(err) + } + if runtime.MediaTimeline().PlayReady { + t.Fatal("E-STOP left wire reserved") + } +} + +func TestMediaTimelinePreparePlayValidation(t *testing.T) { + for _, value := range []MediaPlaybackUpdate{ + {ClientID: "test", Sequence: 1, Rate: 1, PreparePlay: true, Loaded: false, PlanRevision: 1}, + {ClientID: "test", Sequence: 1, Rate: 1, PreparePlay: true, Loaded: true, PlanRevision: 0}, + {ClientID: "test", Sequence: 1, Rate: 1, PreparePlay: true, Loaded: true, Playing: true, PlanRevision: 1}, + } { + if err := value.validate(); err == nil { + t.Fatalf("invalid preparation accepted: %+v", value) + } + } +} + func TestMediaTimelineCompilerFreezesOffsetsAndRejectsInvalidPlans(t *testing.T) { plan := MediaTimelinePlan{ClientID: "test", Revision: 1, Actions: []MediaTimelineAction{ {ID: "on", TimeMS: 10000, Step: appconfig.MacroStep{Kind: "relay", Target: 7, Value: 1}}, @@ -257,11 +335,13 @@ func armTimeline(t *testing.T, runtime *Runtime, at uint64) MediaPlaybackUpdate } value.Playing = false value.PositionMS = at + value.PreparePlay = true if _, err := runtime.UpdateMediaPlayback(value); err != nil { t.Fatal(err) } value.Sequence++ value.Playing = true + value.PreparePlay = false return value } func TestMediaTimelineExecutesBothEdgesWithoutUIOrRepeatedCueRPC(t *testing.T) { @@ -433,8 +513,8 @@ func TestMediaTimelineClockLeaseFaultsInsteadOfSkippingPendingCue(t *testing.T) if status.Acknowledged != 2 { t.Fatalf("unexpected ACK ledger: %+v", status) } - if _, err := runtime.UpdateMediaPlayback(MediaPlaybackUpdate{ClientID: "test", Sequence: 3, Loaded: true, Playing: true, Rate: 1, Epoch: 1, PlanRevision: 1}); err == nil { - t.Fatal("faulted plan resumed") + if _, err := runtime.UpdateMediaPlayback(MediaPlaybackUpdate{ClientID: "test", Sequence: 3, Loaded: true, Playing: true, Rate: 1, Epoch: 1, PlanRevision: 1}); err == nil || !strings.Contains(err.Error(), status.Error) { + t.Fatalf("faulted plan resumed or its original error was masked: %v", err) } } @@ -448,11 +528,13 @@ func TestMediaTimelinePauseResumeRestoresLatchedValueAndSeekRequiresArming(t *te value.Sequence++ value.Playing = false value.PositionMS = 100 + value.PreparePlay = true if _, err := runtime.UpdateMediaPlayback(value); err != nil { t.Fatal(err) } value.Sequence++ value.Playing = true + value.PreparePlay = false if _, err := runtime.UpdateMediaPlayback(value); err != nil { t.Fatal(err) } diff --git a/Tools/Controller/internal/control/media_timeline_wire.go b/Tools/Controller/internal/control/media_timeline_wire.go new file mode 100644 index 00000000..f9dfcf0a --- /dev/null +++ b/Tools/Controller/internal/control/media_timeline_wire.go @@ -0,0 +1,106 @@ +package control + +import ( + "context" + "errors" + "fmt" + "time" + + "pccontroller.local/controller/internal/link" + "pccontroller.local/controller/internal/native" +) + +func (runtime *Runtime) releaseMediaTimelineWire() { + state := &runtime.mediaTimeline + state.mu.Lock() + release := state.releaseWire + state.releaseWire = nil + state.wireRenewedAt = time.Time{} + state.status.PlayReady = false + state.mu.Unlock() + if release != nil { + release() + } +} + +func (runtime *Runtime) expirePausedMediaTimelineWire() { + state := &runtime.mediaTimeline + state.mu.Lock() + if state.releaseWire == nil || time.Since(state.wireRenewedAt) <= mediaTimelineArmAckTimeout { + state.mu.Unlock() + return + } + release := state.releaseWire + state.releaseWire = nil + state.wireRenewedAt = time.Time{} + state.status.PlayReady = false + state.mu.Unlock() + release() +} + +// The timeline owns maintenance too. Refresh host presence/display only in a +// large cue-free gap; never let a separate presentation worker occupy the wire +// across a cue. Dense timelines themselves keep firmware host activity alive. +func (runtime *Runtime) serviceTimelinePresentation(ctx context.Context, value MediaPlaybackUpdate, anchor time.Time, generation uint64) { + ctx, cancel := context.WithTimeout(ctx, 50*time.Millisecond) + defer cancel() + _, err := runtime.requestAtGenerationPriority(ctx, generation, link.RequestPriorityTimeline, + native.OpProgramState, native.ProgramStatePayload(true), native.OpACK) + if err == nil { + _, err = runtime.requestAtGenerationPriority(ctx, generation, link.RequestPriorityTimeline, + native.OpDisplayText, mediaDisplayPayload(value, time.Since(anchor)), native.OpACK) + } + state := &runtime.mediaPlayback + state.mu.Lock() + defer state.mu.Unlock() + state.snapshot.BoardSynced = err == nil + if err == nil { + state.snapshot.BoardSyncedAt = time.Now().UTC() + state.snapshot.BoardSequence = value.Sequence + state.snapshot.BoardError = "" + } else { + state.snapshot.BoardError = fmt.Sprintf("timeline presentation not acknowledged: %v", err) + } +} + +// Called only after the executor acknowledged the paused epoch. Media playback +// operations serialize this with other clock updates; terminal executor state +// is rechecked after draining, because cancellation can happen concurrently. +func (runtime *Runtime) reserveMediaTimelineWire(ctx context.Context, value MediaPlaybackUpdate) error { + state := &runtime.mediaTimeline + state.mu.Lock() + if state.releaseWire != nil && state.status.PlayReady { + state.wireRenewedAt = time.Now() + state.mu.Unlock() + return nil + } + generation := state.status.Generation + state.mu.Unlock() + runtime.mu.RLock() + session := runtime.session + valid := session != nil && runtime.generation == generation && runtime.sessionUsableLocked() + runtime.mu.RUnlock() + if !valid { + return errors.New("prepared board session changed before serial reservation") + } + release, err := session.ReserveTimeline(ctx) + if err != nil { + return err + } + runtime.mu.RLock() + current := runtime.session == session && runtime.generation == generation && runtime.sessionUsableLocked() + runtime.mu.RUnlock() + state.mu.Lock() + status := state.status + if status.ClientID != value.ClientID || status.Revision != value.PlanRevision || + status.ArmedEpoch != value.Epoch || status.State != "paused" || runtime.emergencyStop.Load() || !current { + state.mu.Unlock() + release() + return errors.New("prepared paused epoch changed while reserving serial channel") + } + state.releaseWire = release + state.wireRenewedAt = time.Now() + state.status.PlayReady = true + state.mu.Unlock() + return nil +} diff --git a/Tools/Controller/internal/control/runtime.go b/Tools/Controller/internal/control/runtime.go index 9adc890b..5f6d0abf 100644 --- a/Tools/Controller/internal/control/runtime.go +++ b/Tools/Controller/internal/control/runtime.go @@ -1962,14 +1962,6 @@ func (runtime *Runtime) requestAtGenerationPriority( return native.Frame{}, err } defer segmentUnlock() - if opcode == native.OpPWMGet { - if err := runtime.rejectTimelinePWMReadback(); err != nil { - return native.Frame{}, err - } - // A request admitted while paused may wait behind another transaction. - // Recheck immediately before writing so it cannot leak into playback. - ctx = link.WithRequestWriteGuard(ctx, runtime.rejectTimelinePWMReadback) - } if err := runtime.rejectMediaTimelineConflict(ctx, opcode); err != nil { return native.Frame{}, err } @@ -2004,6 +1996,10 @@ func (runtime *Runtime) requestAtGenerationPriority( } } frame, err := session.RequestWithPriority(ctx, priority, opcode, payload, expected...) + if errors.Is(err, link.ErrTimelineReserved) { + return native.Frame{}, &ResourceBusyError{Resource: "serial_channel", Owner: "media_timeline", + Message: "serial channel reserved for prepared playback; pause before issuing live commands"} + } if err != nil { return native.Frame{}, err } @@ -2901,6 +2897,12 @@ func (runtime *Runtime) syncProgramState(snapshot ProgramStateSnapshot, lifecycl err := runtime.Command(ctx, native.OpProgramState, payload) cancel() if err != nil { + var busy *ResourceBusyError + if errors.As(err, &busy) && busy.Resource == "serial_channel" { + runtime.publishEvent(Event{Kind: "program.state.sync", Lifecycle: "deferred", + State: string(snapshot.Mode), Reason: err.Error(), Text: "program state presentation deferred by prepared playback"}) + return + } runtime.publishEvent(Event{ Kind: "program.state.sync", Lifecycle: "failed", State: string(snapshot.Mode), Reason: err.Error(), @@ -3004,6 +3006,12 @@ type boardLivenessFailureTracker struct { } func (tracker *boardLivenessFailureTracker) observe(err error) bool { + var busy *ResourceBusyError + if errors.As(err, &busy) && busy.Resource == "serial_channel" && busy.Owner == "media_timeline" { + // No probe went on the wire. Neither diagnose a lost board nor invent + // a successful liveness response from intentional admission deferral. + return false + } if err == nil { tracker.failures = 0 return false diff --git a/Tools/Controller/internal/ipcjson/pwm_channels.go b/Tools/Controller/internal/ipcjson/pwm_channels.go index ca4ebf9e..6493e580 100644 --- a/Tools/Controller/internal/ipcjson/pwm_channels.go +++ b/Tools/Controller/internal/ipcjson/pwm_channels.go @@ -36,7 +36,7 @@ type pwmValues struct { } func (service *Service) pwmValues(ctx context.Context) (pwmValues, error) { - if service.Client.MediaTimeline().State == "playing" { + if service.Client.MediaTimeline().PlayReady { // Preserve the observation's generation, revision and original time. // Playback must not create polling traffic or pretend this is a new ACK. if values := service.cachedPWMValues(); values != nil { diff --git a/Tools/Controller/internal/link/session.go b/Tools/Controller/internal/link/session.go index 3b14452a..5910a59f 100644 --- a/Tools/Controller/internal/link/session.go +++ b/Tools/Controller/internal/link/session.go @@ -29,6 +29,7 @@ var ( ErrSerialOpenPending = errors.New("serial port acquisition is still pending") ErrSequenceExhaust = errors.New("all request sequence numbers are in use") ErrControlLinesUnsupported = errors.New("transport does not support DTR/RTS") + ErrTimelineReserved = errors.New("serial channel is reserved for prepared playback") ) var openSerialPort = serial.Open @@ -103,18 +104,21 @@ const ( RequestPriorityBackground RequestPriority = iota RequestPriorityNormal RequestPriorityTimeline + RequestPrioritySafety ) type requestWaiter struct { priority RequestPriority ready chan struct{} granted bool + err error } type requestAdmission struct { - mu sync.Mutex - active bool - waiters []*requestWaiter + mu sync.Mutex + active bool + reserved bool + waiters []*requestWaiter } func (admission *requestAdmission) acquire( @@ -133,6 +137,10 @@ func (admission *requestAdmission) acquire( waiter := &requestWaiter{priority: priority, ready: make(chan struct{})} admission.mu.Lock() + if admission.reserved && priority < RequestPriorityTimeline { + admission.mu.Unlock() + return nil, ErrTimelineReserved + } if !admission.active && len(admission.waiters) == 0 { admission.active = true admission.mu.Unlock() @@ -143,6 +151,9 @@ func (admission *requestAdmission) acquire( select { case <-waiter.ready: + if waiter.err != nil { + return nil, waiter.err + } return admission.release, nil case <-ctx.Done(): admission.cancel(waiter) @@ -153,6 +164,49 @@ func (admission *requestAdmission) acquire( } } +// ReserveTimeline fences normal/background traffic before draining the current +// transaction. It never cancels bytes already sent. The returned lease permits +// timeline and safety commands until its owner releases it. +func (s *Session) ReserveTimeline(ctx context.Context) (func(), error) { + a := &s.requestAdmission + a.mu.Lock() + if a.reserved { + a.mu.Unlock() + return nil, ErrTimelineReserved + } + a.reserved = true + kept := a.waiters[:0] + for _, waiter := range a.waiters { + if waiter.priority < RequestPriorityTimeline { + waiter.err = ErrTimelineReserved + close(waiter.ready) + } else { + kept = append(kept, waiter) + } + } + a.waiters = kept + a.mu.Unlock() + var once sync.Once + release := func() { + once.Do(func() { + a.mu.Lock() + a.reserved = false + a.mu.Unlock() + }) + } + drain, err := a.acquire(ctx, s.closing, RequestPriorityTimeline) + if err != nil { + release() + return nil, err + } + drain() + if err := ctx.Err(); err != nil { + release() + return nil, err + } + return release, nil +} + func (admission *requestAdmission) cancel(waiter *requestWaiter) { admission.mu.Lock() defer admission.mu.Unlock() diff --git a/Tools/Controller/internal/link/timeline_reservation_test.go b/Tools/Controller/internal/link/timeline_reservation_test.go new file mode 100644 index 00000000..f50c0834 --- /dev/null +++ b/Tools/Controller/internal/link/timeline_reservation_test.go @@ -0,0 +1,97 @@ +package link + +import ( + "context" + "errors" + "testing" + "time" +) + +func TestTimelineReservationDrainsActiveAndRejectsQueuedPresentation(t *testing.T) { + session := NewForPort("RESERVATION", newFakePort()) + defer session.Close() + a := &session.requestAdmission + active, err := a.acquire(context.Background(), session.closing, RequestPriorityNormal) + if err != nil { + t.Fatal(err) + } + queued := make(chan error, 1) + go func() { + release, err := a.acquire(context.Background(), session.closing, RequestPriorityNormal) + if release != nil { + release() + } + queued <- err + }() + waitForQueuedRequests(t, a, 1) + reserved := make(chan func(), 1) + failed := make(chan error, 1) + go func() { + release, err := session.ReserveTimeline(context.Background()) + if err != nil { + failed <- err + return + } + reserved <- release + }() + select { + case err := <-queued: + if !errors.Is(err, ErrTimelineReserved) { + t.Fatal(err) + } + case <-time.After(time.Second): + t.Fatal("queued presentation was not rejected") + } + select { + case <-reserved: + t.Fatal("reservation acknowledged before the active request drained") + case err := <-failed: + t.Fatal(err) + default: + } + active() + var releaseReservation func() + select { + case releaseReservation = <-reserved: + case err := <-failed: + t.Fatal(err) + case <-time.After(time.Second): + t.Fatal("reservation did not drain") + } + defer releaseReservation() + for _, priority := range []RequestPriority{RequestPriorityBackground, RequestPriorityNormal} { + if _, err := a.acquire(context.Background(), session.closing, priority); !errors.Is(err, ErrTimelineReserved) { + t.Fatalf("priority %d: %v", priority, err) + } + } + for _, priority := range []RequestPriority{RequestPriorityTimeline, RequestPrioritySafety} { + release, err := a.acquire(context.Background(), session.closing, priority) + if err != nil { + t.Fatal(err) + } + release() + } + releaseReservation() + release, err := a.acquire(context.Background(), session.closing, RequestPriorityNormal) + if err != nil { + t.Fatal(err) + } + release() +} + +func TestTimelineReservationTimeoutDoesNotStrandAdmission(t *testing.T) { + session := NewForPort("RESERVATION", newFakePort()) + defer session.Close() + active, _ := session.requestAdmission.acquire(context.Background(), session.closing, RequestPriorityNormal) + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Millisecond) + defer cancel() + if _, err := session.ReserveTimeline(ctx); !errors.Is(err, context.DeadlineExceeded) { + t.Fatal(err) + } + active() + release, err := session.requestAdmission.acquire(context.Background(), session.closing, RequestPriorityNormal) + if err != nil { + t.Fatal(err) + } + release() +} diff --git a/docs/Prepared-Media-Timeline.md b/docs/Prepared-Media-Timeline.md index b4cec31c..bba183ce 100644 --- a/docs/Prepared-Media-Timeline.md +++ b/docs/Prepared-Media-Timeline.md @@ -27,6 +27,32 @@ Playing against an unprepared/faulted revision or unarmed epoch is rejected. A paused update must first arm the cursor. Clock extrapolation retains a Go monotonic anchor; public UTC timestamps are not used as scheduling clocks. +Before starting a nonempty plan, send a paused, loaded update with +`prepare_play: true`. The host arms that exact revision/epoch and reserves the +serial channel within the same 250 ms arming budget, draining the current +transaction without canceling it. Wait for matching feedback with +`timeline.play_ready: true` before unpausing the media. The pending-start client +renews its paused intent at 100 ms intervals until the decoder actually starts; +only then does it publish `playing: true` and `prepare_play: false`. A paused +`prepare_play: false` cancels the reservation after output-off cleanup. Ordinary +paused arming and empty plans do not require a serial reservation. + +The paused reservation's 250 ms lease starts at its actual grant/renewal, while +the original clock receipt anchor remains unchanged. Expiry, pause, terminal +fault, plan replacement or disconnect revokes readiness. Playing without the +matching reservation is rejected. This is the current contract: deploy the +updated host and clock publisher together; an old client cannot skip it. + +While reserved, queued/new normal and background requests return a resource +conflict before writing. Timeline and safety requests remain admissible. +Intentional deferral is not a failed liveness probe and cannot cause DTR recovery. +The prepared executor owns optional ProgramState/display maintenance too: at +most every two seconds in cue-free gaps of at least 250 ms, with a shared 50 ms +request budget. Dense command streams themselves renew firmware host presence. +Display refresh is honestly deferred where no safe gap exists; its old readback +is not reported as a newly synchronized display. Actuator deadlines stay intact. +The original terminal fault is retained in later rejected playing updates. + `controller.media.timeline.get` and playback snapshot `timeline` return: revision/hash/generation/state, armed epoch/clock sequence, command count and ACK count, explicitly rebased steps, last command/deadline, last/max ACK lateness, diff --git a/docs/STRIP-WORKSPACE-INTEGRATION.md b/docs/STRIP-WORKSPACE-INTEGRATION.md index a4272e7e..abc3bd5b 100644 --- a/docs/STRIP-WORKSPACE-INTEGRATION.md +++ b/docs/STRIP-WORKSPACE-INTEGRATION.md @@ -43,17 +43,21 @@ serial slot for 88 ms. The strip configure cue reached dispatch about 2 ms late, then exhausted its unchanged 50 ms deadline behind that read. Framing and CRC counters remained unchanged: this is a separate scheduling conflict. -Fresh PWM reads now return a structured resource conflict during prepared -playback, with a second check immediately before the wire write. The PWM status -RPC serves the existing observation during playback without changing its time, -generation or revision. Resume fences new reads before checking registered ones; -an already in-flight PWM read rejects Play without accepting a playing clock. -The read is not canceled, and no mutation is retried. Pause releases the fence. - -This deliberately does not preempt an in-flight request. A client must handle -the pre-Play resource conflict honestly; seamless resume under that contention -and the final live synchronized replay remain acceptance gates. The earlier -ProgramState contention observed on ci.1631 is not claimed fixed by this change. +A subsequent ci.1648 replay exposed internal display refresh (0x38) blocking +resume after the PWM-only guard. The current solution reserves the whole serial +channel during an explicit paused `prepare_play` handshake. Pealayer must wait +for matching `play_ready` feedback before unpausing. Queued/background work is +rejected before writing; in-flight work drains within the original arming budget. +The PWM status RPC retains the observation's time, generation and revision. +Pause, cancellation, stale intent, terminal faults and disconnect release the +reservation. Deferred health probes cannot trigger DTR recovery. Optional board +presentation runs through the timeline owner only in sufficiently large gaps; +dense playback defers it honestly. See [the complete contract](Prepared-Media-Timeline.md). + +The new host and Pealayer publisher must be deployed together. Final synchronized +replay, dense/sparse playback and human physical acceptance remain open until +verified on that exact pair. Neither the PWM-only intermediate update nor the +standalone strip soak establishes synchronized playback acceptance. Deployment evidence must include live compiled version/source identity from the current listener. A prior updater reported a new disk digest while an old From af8f7ca4e90e285634f40f15c19ea18665e96fd6 Mon Sep 17 00:00:00 2001 From: DRSDavidSoft <4673812+DRSDavidSoft@users.noreply.github.com> Date: Tue, 27 Oct 2026 02:10:47 +0330 Subject: [PATCH 08/19] fix: preserve safety access during serial reservation --- .../internal/control/emergency_stop.go | 3 +++ .../internal/control/media_timeline.go | 2 +- .../internal/control/media_timeline_test.go | 21 ++++++++++++++- .../internal/control/media_timeline_wire.go | 26 ++++++++++++++----- Tools/Controller/internal/control/runtime.go | 3 +++ 5 files changed, 47 insertions(+), 8 deletions(-) diff --git a/Tools/Controller/internal/control/emergency_stop.go b/Tools/Controller/internal/control/emergency_stop.go index eaf95d75..8503d676 100644 --- a/Tools/Controller/internal/control/emergency_stop.go +++ b/Tools/Controller/internal/control/emergency_stop.go @@ -12,6 +12,8 @@ import ( "pccontroller.local/controller/internal/native" ) +type emergencySafetyContextKey struct{} + // EmergencyStopState is the host-authoritative, transport-neutral motion and // effect interlock. Unlocking removes the command block but never resumes work // that was cancelled when the latch engaged. @@ -97,6 +99,7 @@ func (runtime *Runtime) reassertEmergencyStop() { } func (runtime *Runtime) applyEmergencyStopTerminalState(ctx context.Context) []error { + ctx = context.WithValue(ctx, emergencySafetyContextKey{}, true) var stopErrors []error // Repeat the board-safe terminal state after cancellation. This defeats a // final in-flight macro/stream frame without ever energizing an output. diff --git a/Tools/Controller/internal/control/media_timeline.go b/Tools/Controller/internal/control/media_timeline.go index a0c4096d..6ed4f297 100644 --- a/Tools/Controller/internal/control/media_timeline.go +++ b/Tools/Controller/internal/control/media_timeline.go @@ -113,7 +113,7 @@ type mediaTimelineState struct { type mediaTimelineContextKey struct{} func (runtime *Runtime) rejectMediaTimelineConflict(ctx context.Context, opcode byte) error { - if ctx.Value(mediaTimelineContextKey{}) != nil { + if ctx.Value(mediaTimelineContextKey{}) != nil || ctx.Value(emergencySafetyContextKey{}) != nil { return nil } status := runtime.MediaTimeline() diff --git a/Tools/Controller/internal/control/media_timeline_test.go b/Tools/Controller/internal/control/media_timeline_test.go index e50751c2..62e6ab58 100644 --- a/Tools/Controller/internal/control/media_timeline_test.go +++ b/Tools/Controller/internal/control/media_timeline_test.go @@ -180,6 +180,25 @@ func TestMediaTimelineEmergencyStopReleasesPausedReservation(t *testing.T) { } } +func TestMediaTimelineEmergencyStopBypassesReservationStillBeingGranted(t *testing.T) { + runtime, _, _ := mediaTimelineFixture(t, false) + runtime.mu.RLock() + session := runtime.session + runtime.mu.RUnlock() + // Model the reservation's drain phase: the link has fenced admission but + // no completed lease has yet been installed in mediaTimeline.releaseWire. + release, err := session.ReserveTimeline(context.Background()) + if err != nil { + t.Fatal(err) + } + defer release() + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + if _, err := runtime.SetEmergencyStop(ctx, true, "test", "pending reservation"); err != nil { + t.Fatalf("safety cleanup was blocked by pending reservation: %v", err) + } +} + func TestMediaTimelinePreparePlayValidation(t *testing.T) { for _, value := range []MediaPlaybackUpdate{ {ClientID: "test", Sequence: 1, Rate: 1, PreparePlay: true, Loaded: false, PlanRevision: 1}, @@ -303,7 +322,7 @@ func mediaTimelineFixture(t *testing.T, nack bool) (*Runtime, *[]native.Frame, * } } }() - runtime.attach(link.OpenResult{Session: link.NewForPort("TIMELINE-TEST", port), Port: ports.Info{Name: "TIMELINE-TEST"}, Hello: native.Hello{Name: "Virtual"}}) + runtime.attach(link.OpenResult{Session: link.NewForPort("TIMELINE-TEST", port), Port: ports.Info{Name: "TIMELINE-TEST"}, Hello: native.Hello{Name: "Virtual", Capabilities: native.CapabilityProgramState | native.CapabilityScheduledSegments}}) t.Cleanup(func() { runtime.Close() }) return runtime, &frames, &mu } diff --git a/Tools/Controller/internal/control/media_timeline_wire.go b/Tools/Controller/internal/control/media_timeline_wire.go index f9dfcf0a..e9a3181f 100644 --- a/Tools/Controller/internal/control/media_timeline_wire.go +++ b/Tools/Controller/internal/control/media_timeline_wire.go @@ -44,22 +44,36 @@ func (runtime *Runtime) expirePausedMediaTimelineWire() { func (runtime *Runtime) serviceTimelinePresentation(ctx context.Context, value MediaPlaybackUpdate, anchor time.Time, generation uint64) { ctx, cancel := context.WithTimeout(ctx, 50*time.Millisecond) defer cancel() - _, err := runtime.requestAtGenerationPriority(ctx, generation, link.RequestPriorityTimeline, - native.OpProgramState, native.ProgramStatePayload(true), native.OpACK) - if err == nil { + runtime.mu.RLock() + capabilities := runtime.hello.Capabilities + runtime.mu.RUnlock() + programSupported := capabilities&native.CapabilityProgramState != 0 + displaySupported := capabilities&native.CapabilityScheduledSegments != 0 + var err error + if programSupported { + _, err = runtime.requestAtGenerationPriority(ctx, generation, link.RequestPriorityTimeline, + native.OpProgramState, native.ProgramStatePayload(true), native.OpACK) + } + if displaySupported && err == nil { _, err = runtime.requestAtGenerationPriority(ctx, generation, link.RequestPriorityTimeline, native.OpDisplayText, mediaDisplayPayload(value, time.Since(anchor)), native.OpACK) } + if !programSupported && !displaySupported { + _, err = runtime.requestAtGenerationPriority(ctx, generation, link.RequestPriorityTimeline, + native.OpGetStatus, nil, native.OpStatus) + } state := &runtime.mediaPlayback state.mu.Lock() defer state.mu.Unlock() - state.snapshot.BoardSynced = err == nil - if err == nil { + state.snapshot.BoardSynced = err == nil && displaySupported + if err == nil && displaySupported { state.snapshot.BoardSyncedAt = time.Now().UTC() state.snapshot.BoardSequence = value.Sequence state.snapshot.BoardError = "" - } else { + } else if err != nil { state.snapshot.BoardError = fmt.Sprintf("timeline presentation not acknowledged: %v", err) + } else { + state.snapshot.BoardError = "board does not advertise scheduled clock display" } } diff --git a/Tools/Controller/internal/control/runtime.go b/Tools/Controller/internal/control/runtime.go index 5f6d0abf..5d817829 100644 --- a/Tools/Controller/internal/control/runtime.go +++ b/Tools/Controller/internal/control/runtime.go @@ -1990,6 +1990,9 @@ func (runtime *Runtime) requestAtGenerationPriority( session := runtime.session runtime.mu.RUnlock() traceEpoch := runtime.pwmTraceAdmission(opcode, payload) + if ctx.Value(emergencySafetyContextKey{}) != nil { + priority = link.RequestPrioritySafety + } if opcode == native.OpPWMSet { if guard, ok := ctx.Value(rfRollGuardKey{}).(func() error); ok { ctx = link.WithRequestWriteGuard(ctx, guard) From 4437f0632f7603a98a0c31422edb4774819230a3 Mon Sep 17 00:00:00 2001 From: DRSDavidSoft <4673812+DRSDavidSoft@users.noreply.github.com> Date: Tue, 27 Oct 2026 02:41:21 +0330 Subject: [PATCH 09/19] Classify playback deferrals and retain serial timeout timing --- Tools/Controller/controller.go | 7 +++ .../internal/control/lcd_presenter.go | 6 +++ .../internal/control/lcd_presenter_test.go | 30 +++++++++++ .../internal/control/media_timeline.go | 3 +- Tools/Controller/internal/control/runtime.go | 9 +++- .../internal/link/response_timing_test.go | 51 +++++++++++++++++++ Tools/Controller/internal/link/session.go | 51 ++++++++++++++----- Tools/Controller/status_subscription_test.go | 44 ++++++++++++++++ docs/STRIP-WORKSPACE-INTEGRATION.md | 25 +++++++++ 9 files changed, 212 insertions(+), 14 deletions(-) create mode 100644 Tools/Controller/internal/link/response_timing_test.go diff --git a/Tools/Controller/controller.go b/Tools/Controller/controller.go index 47166de3..1eed2bf2 100644 --- a/Tools/Controller/controller.go +++ b/Tools/Controller/controller.go @@ -1460,6 +1460,8 @@ func (client *Client) runStatusSubscriptionHub() { } cancel() update := StatusUpdate{Time: at, Status: status} + var busy *control.ResourceBusyError + deferred := errors.As(err, &busy) && busy.Resource == "serial_channel" && busy.Owner == "media_timeline" if err != nil { update.Error = err.Error() } @@ -1471,6 +1473,11 @@ func (client *Client) runStatusSubscriptionHub() { for !subscriber.next.After(at) { subscriber.next = subscriber.next.Add(subscriber.interval) } + if deferred { + // No query reached the board. Advance cadence without inventing a + // fresh sample or publishing a controller.error to every player. + continue + } if err != nil { backoff := at.Add(time.Second) if subscriber.next.Before(backoff) { diff --git a/Tools/Controller/internal/control/lcd_presenter.go b/Tools/Controller/internal/control/lcd_presenter.go index d9ce1b13..46eda46a 100644 --- a/Tools/Controller/internal/control/lcd_presenter.go +++ b/Tools/Controller/internal/control/lcd_presenter.go @@ -2,6 +2,7 @@ package control import ( "context" + "errors" "fmt" "strings" "sync" @@ -427,6 +428,11 @@ func (presenter *LCDPresenter) send(version uint64, line1, line2 string) { line1, line2 = lcdLine(line1), lcdLine(line2) err = presenter.runtime.Command(ctx, native.OpDisplayText, payload) if err != nil { + var busy *ResourceBusyError + if errors.As(err, &busy) && busy.Resource == "serial_channel" && busy.Owner == "media_timeline" { + presenter.runtime.PublishHostEvent("lcd.deferred", "LCD presentation deferred by prepared playback") + return + } // Keep transport failures out of the generic error priority path: feeding // them back into the presenter would recursively retry an unavailable LCD. presenter.runtime.PublishHostEvent("lcd.error", "LCD presentation: "+err.Error()) diff --git a/Tools/Controller/internal/control/lcd_presenter_test.go b/Tools/Controller/internal/control/lcd_presenter_test.go index 4fdf7faf..16b09354 100644 --- a/Tools/Controller/internal/control/lcd_presenter_test.go +++ b/Tools/Controller/internal/control/lcd_presenter_test.go @@ -9,6 +9,36 @@ import ( "pccontroller.local/controller/internal/native" ) +func TestLCDTimelineDeferralDoesNotClaimErrorOrConfirmedDisplay(t *testing.T) { + runtime, _, _ := mediaTimelineFixture(t, false) + runtime.mediaTimeline.mu.Lock() + runtime.mediaTimeline.status.State = "playing" + runtime.mediaTimeline.mu.Unlock() + err := runtime.rejectMediaTimelineConflict(context.Background(), native.OpDisplayText) + var busy *ResourceBusyError + if !errors.As(err, &busy) || busy.Resource != "serial_channel" || busy.Owner != "media_timeline" { + t.Fatalf("output conflict lost typed ownership: %v", err) + } + presenter := NewLCDPresenter(runtime) + presenter.options.Enabled = true + presenter.send(0, "waiting", "for playback") + if presenter.firmwareLines != [2]string{} || presenter.firmwareDevice != "" { + t.Fatal("deferred presentation was reported as confirmed") + } + runtime.eventMu.Lock() + defer runtime.eventMu.Unlock() + deferred := false + for _, event := range runtime.eventLog { + if event.Kind == "lcd.error" { + t.Fatalf("intentional reservation reported as error: %s", event.Text) + } + deferred = deferred || event.Kind == "lcd.deferred" + } + if !deferred { + t.Fatal("missing honest deferral event") + } +} + func TestLCDPromptChangedDuringPriorityRestoresNewestPrompt(t *testing.T) { presenter := NewLCDPresenter(nil) if err := presenter.Configure(LCDPresentationOptions{ diff --git a/Tools/Controller/internal/control/media_timeline.go b/Tools/Controller/internal/control/media_timeline.go index 6ed4f297..1e801529 100644 --- a/Tools/Controller/internal/control/media_timeline.go +++ b/Tools/Controller/internal/control/media_timeline.go @@ -118,7 +118,8 @@ func (runtime *Runtime) rejectMediaTimelineConflict(ctx context.Context, opcode } status := runtime.MediaTimeline() if status.State == "playing" && hostRecordableOpcode(opcode) { - return errors.New("pause the prepared media timeline before issuing competing live output commands") + return &ResourceBusyError{Resource: "serial_channel", Owner: "media_timeline", + Message: "pause the prepared media timeline before issuing competing live output commands"} } return nil } diff --git a/Tools/Controller/internal/control/runtime.go b/Tools/Controller/internal/control/runtime.go index 5d817829..723448a7 100644 --- a/Tools/Controller/internal/control/runtime.go +++ b/Tools/Controller/internal/control/runtime.go @@ -3331,7 +3331,14 @@ func (runtime *Runtime) pump(session *link.Session, generation uint64) { } runtime.publishEvent(deviceEvent) } else { - runtime.publish(kind, text, event.Frame) + if event.Frame.Opcode == native.OpACK && !event.ReceivedAt.IsZero() { + runtime.publishEvent(Event{Kind: kind, Text: text, Frame: event.Frame, Source: "board", Target: "host", Metadata: map[string]string{ + "transport_received_at": event.ReceivedAt.Format(time.RFC3339Nano), + "observer_delay_ms": fmt.Sprintf("%.3f", time.Since(event.ReceivedAt).Seconds()*1000), + }}) + } else { + runtime.publish(kind, text, event.Frame) + } } if rfMappingRequired && parsedDevice != nil { runtime.publishRFMappingRequired(*parsedDevice, rfCaptured) diff --git a/Tools/Controller/internal/link/response_timing_test.go b/Tools/Controller/internal/link/response_timing_test.go new file mode 100644 index 00000000..c1191cef --- /dev/null +++ b/Tools/Controller/internal/link/response_timing_test.go @@ -0,0 +1,51 @@ +package link + +import ( + "context" + "errors" + "strings" + "testing" + "time" + + "pccontroller.local/controller/internal/native" +) + +func TestLateACKRetainsReceiveTimeAfterRequestCancellation(t *testing.T) { + port := newFakePort() + session := NewForPort("TEST", port) + defer session.Close() + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + var request native.Frame + port.onWrite = func(encoded []byte) { + var err error + request, err = native.Decode(encoded) + if err != nil { + t.Fatal(err) + } + cancel() + } + _, err := session.Request(ctx, native.OpRelayAllOff, nil, native.OpACK) + if !errors.Is(err, context.Canceled) { + t.Fatalf("cancellation lost: %v", err) + } + for _, field := range []string{"seq=", "admission=", "write=", "response=", "read_return_age=", "data_age="} { + if !strings.Contains(err.Error(), field) { + t.Fatalf("missing %s in %v", field, err) + } + } + lateACK, err := native.Encode(native.Frame{Opcode: native.OpACK, Seq: request.Seq, Payload: []byte{native.OpRelayAllOff, 0}}) + if err != nil { + t.Fatal(err) + } + before := time.Now() + port.reads <- lateACK + select { + case event := <-session.Events(): + if event.Frame.Opcode != native.OpACK || event.ReceivedAt.Before(before) || event.ReceivedAt.After(time.Now()) { + t.Fatalf("late ACK lost receive evidence: %#v", event) + } + case <-time.After(time.Second): + t.Fatal("late ACK was swallowed by the canceled waiter") + } +} diff --git a/Tools/Controller/internal/link/session.go b/Tools/Controller/internal/link/session.go index 5910a59f..f3427dd2 100644 --- a/Tools/Controller/internal/link/session.go +++ b/Tools/Controller/internal/link/session.go @@ -9,6 +9,7 @@ import ( "net/url" "strings" "sync" + "sync/atomic" "time" "go.bug.st/serial" @@ -46,6 +47,8 @@ type serialOpenAcquisition struct { type Event struct { Frame native.Frame Err error + // ReceivedAt is when Read returned these bytes, before decoder/observer work. + ReceivedAt time.Time // Recoverable identifies a decoder anomaly that was isolated at the next // frame delimiter. The transport is still usable and later frames remain // authoritative; consumers must not treat this as a disconnect cause. @@ -69,16 +72,18 @@ type Session struct { closing chan struct{} done chan struct{} - closeMu sync.Mutex - observerMu sync.Mutex - beforeClose func() - closeSeen bool - closingOnce sync.Once - doneOnce sync.Once - closed bool - readDone sync.WaitGroup - pendingOpen *serialOpenAcquisition - rfWire rfWireState + closeMu sync.Mutex + observerMu sync.Mutex + beforeClose func() + closeSeen bool + closingOnce sync.Once + doneOnce sync.Once + closed bool + readDone sync.WaitGroup + pendingOpen *serialOpenAcquisition + rfWire rfWireState + lastReadReturned atomic.Int64 + lastDataReceived atomic.Int64 } type sessionPort interface { @@ -526,6 +531,7 @@ func (s *Session) RequestWithPriority( return native.Frame{}, s.requestSlotError(opcode, waitStarted, err) } defer releaseRequest() + admittedAt := time.Now() if opcode == native.OpAddressableLED { var cancel context.CancelFunc ctx, cancel = context.WithTimeout(ctx, stripResponseTimeout) @@ -548,14 +554,16 @@ func (s *Session) RequestWithPriority( if err != nil { return native.Frame{}, err } + writeStarted := time.Now() if err := s.writeRawContext(ctx, encoded); err != nil { return native.Frame{}, err } + writeFinished := time.Now() for { select { case <-ctx.Done(): - return native.Frame{}, fmt.Errorf("waiting for opcode 0x%02X response: %w", opcode, ctx.Err()) + return native.Frame{}, s.responseWaitError(opcode, sequence, waitStarted, admittedAt, writeStarted, writeFinished, ctx.Err()) case <-s.closing: return native.Frame{}, ErrClosed case response := <-waiter.channel: @@ -577,6 +585,22 @@ func (s *Session) RequestWithPriority( } } +// Diagnostic ages measure host receive-pump progress, not MCU execution time. +// Keep sequence and phase timing without including command payloads or paths. +func (s *Session) responseWaitError(opcode, sequence byte, started, admitted, writeStarted, written time.Time, cause error) error { + now := time.Now() + age := func(stamp int64) float64 { + if stamp == 0 { + return -1 + } + return float64(now.UnixNano()-stamp) / float64(time.Millisecond) + } + return fmt.Errorf("waiting for opcode 0x%02X response: %w (seq=%d admission=%.1f ms prepare=%.1f ms write=%.1f ms response=%.1f ms read_return_age=%.1f ms data_age=%.1f ms)", + opcode, cause, sequence, admitted.Sub(started).Seconds()*1000, writeStarted.Sub(admitted).Seconds()*1000, + written.Sub(writeStarted).Seconds()*1000, now.Sub(written).Seconds()*1000, + age(s.lastReadReturned.Load()), age(s.lastDataReceived.Load())) +} + // Capture the registered request occupying the slot at failure, without // exposing command payloads, device names or client information. Admission may // race with completion, so explicitly report when no request is registered. @@ -818,7 +842,10 @@ func (s *Session) readLoop() { for { n, err := s.port.Read(buffer) + receivedAt := time.Now() + s.lastReadReturned.Store(receivedAt.UnixNano()) if n > 0 { + s.lastDataReceived.Store(receivedAt.UnixNano()) frames, decodeErrors := decoder.Feed(buffer[:n]) for _, decodeErr := range decodeErrors { s.publish(Event{Err: decodeErr, Recoverable: true}) @@ -827,7 +854,7 @@ func (s *Session) readLoop() { s.observeRFClock(frame) s.observeHello(frame) if !s.deliver(frame) { - s.publish(Event{Frame: frame}) + s.publish(Event{Frame: frame, ReceivedAt: receivedAt}) } } } diff --git a/Tools/Controller/status_subscription_test.go b/Tools/Controller/status_subscription_test.go index 0a33cb61..b4d13d3f 100644 --- a/Tools/Controller/status_subscription_test.go +++ b/Tools/Controller/status_subscription_test.go @@ -7,9 +7,53 @@ import ( "testing" "time" + "pccontroller.local/controller/internal/control" "pccontroller.local/controller/internal/native" ) +func TestStatusSubscriptionReservationDefersWithoutErrorOrFakeSample(t *testing.T) { + var calls atomic.Int32 + secondFetch := make(chan struct{}) + release := make(chan struct{}) + var once sync.Once + defer once.Do(func() { close(release) }) + client := &Client{statusFetch: func(context.Context) (Status, error) { + if calls.Add(1) == 1 { + return Status{}, &control.ResourceBusyError{Resource: "serial_channel", Owner: "media_timeline"} + } + if calls.Load() == 2 { + close(secondFetch) + } + <-release + return Status{SupplyMV: 12345}, nil + }} + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + updates, err := client.SubscribeStatus(ctx, 50*time.Millisecond) + if err != nil { + t.Fatal(err) + } + select { + case <-secondFetch: + case <-time.After(time.Second): + t.Fatal("deferred subscription did not advance its cadence") + } + select { + case update := <-updates: + t.Fatalf("deferral published an error or fake sample: %+v", update) + default: + } + once.Do(func() { close(release) }) + select { + case update := <-updates: + if update.Error != "" || update.Status.SupplyMV != 12345 { + t.Fatalf("bad resumed observation: %+v", update) + } + case <-time.After(time.Second): + t.Fatal("subscription did not resume after deferral") + } +} + func TestStatusSubscriptionHubDoesNotMultiplyPhysicalPolling(t *testing.T) { var fetches atomic.Int32 fetchStarted := make(chan struct{}, 2) diff --git a/docs/STRIP-WORKSPACE-INTEGRATION.md b/docs/STRIP-WORKSPACE-INTEGRATION.md index abc3bd5b..e8396fdc 100644 --- a/docs/STRIP-WORKSPACE-INTEGRATION.md +++ b/docs/STRIP-WORKSPACE-INTEGRATION.md @@ -64,3 +64,28 @@ the current listener. A prior updater reported a new disk digest while an old TUI subsequently took primary ownership. That old process was gracefully closed with operator approval and the ci.1647 listener verified separately. A disk artifact manifest alone does not prove which executable image owns the board. + +The paired ci.1650 / Pealayer c48c2e7 live replay reached the reserved channel, +but failed on a resume relay ACK deadline (47.6 ms). The unmatched ACK appeared +later, with its MCU timestamp only 280 microseconds after the relay edge. +Framing/CRC remained zero and the reset count did not change; cleanup stopped +both seats and cleared relays. This is not synchronized-playback acceptance. +The existing history time is publication time, so it cannot establish whether +the remaining delay occurred in the driver, reader, or event observer. + +Response timeout diagnostics now retain the sequence and admission, preparation, +write and response-wait durations, plus ages of the last Read return and received +data (-1 means no observation). Write timing includes the writer gate. Unmatched +ACK history separately records the Read-return timestamp and delay to observer +publication. These are host observations, not MCU execution durations; no cue or +freshness deadline changes and no retry of an uncertain mutation are introduced. + +A bounded read-only Normal/AboveNormal process-priority comparison did not +improve latency tails; the original priority was restored. A second playback +attempt restored outputs within 30 ms and entered playback, but the test harness +mistook an intentional reservation notification for a fatal error and paused it. +That incomplete run is not a playback pass. The host subscription producer now +recognizes only the typed serial-channel/media-timeline deferral: it advances +its polling cadence without emitting an error or a fabricated fresh observation. +Other failures remain visible. Competing output commands retain the same typed +ownership error, and automatic LCD presentation reports deferral honestly. From 63a2314011f5a49e9fcf5e6679691f9984d2f108 Mon Sep 17 00:00:00 2001 From: DRSDavidSoft <4673812+DRSDavidSoft@users.noreply.github.com> Date: Tue, 27 Oct 2026 03:04:25 +0330 Subject: [PATCH 10/19] Trace executor scheduling and enforce restore ACK deadline --- .../internal/control/media_timeline.go | 17 ++++++++- .../internal/control/media_timeline_test.go | 13 +++++++ .../internal/ipcjson/cpu_profile.go | 17 +++++++-- .../internal/ipcjson/cpu_profile_test.go | 36 +++++++++++++++---- Tools/Controller/internal/ipcjson/ipc.go | 15 +++++--- docs/STRIP-WORKSPACE-INTEGRATION.md | 11 ++++++ 6 files changed, 94 insertions(+), 15 deletions(-) diff --git a/Tools/Controller/internal/control/media_timeline.go b/Tools/Controller/internal/control/media_timeline.go index 1e801529..a844bb4a 100644 --- a/Tools/Controller/internal/control/media_timeline.go +++ b/Tools/Controller/internal/control/media_timeline.go @@ -496,6 +496,19 @@ func mediaTimelineRestore(steps []mediaTimelineStep, index int) []mediaTimelineS } return restored } + +// A timer callback may itself be delayed. A successful response must still +// satisfy the elapsed budget; receiving an ACK does not make a late edge timely. +func mediaTimelineRestoreAckError(err error, elapsed, budget time.Duration) error { + if err != nil { + return err + } + if elapsed > budget { + return fmt.Errorf("ACK exceeded %.1f ms restore budget", budget.Seconds()*1000) + } + return nil +} + func (runtime *Runtime) runMediaTimeline(ctx context.Context, done chan struct{}, plan MediaTimelinePlan, steps []mediaTimelineStep, generation uint64) { defer close(done) defer runtime.releaseMediaTimelineWire() @@ -627,7 +640,9 @@ func (runtime *Runtime) runMediaTimeline(ctx context.Context, done chan struct{} requestStarted := time.Now() frame, err := runtime.requestAtGenerationPriority(requestCtx, generation, link.RequestPriorityTimeline, step.opcode, step.payload, native.OpACK) cancel() - ackMS := time.Since(requestStarted).Seconds() * 1000 + elapsed := time.Since(requestStarted) + ackMS := elapsed.Seconds() * 1000 + err = mediaTimelineRestoreAckError(err, elapsed, time.Duration(plan.MaxLatenessMS)*time.Millisecond) runtime.mediaTimeline.mu.Lock() runtime.mediaTimeline.status.LastRestoreStep = step.id runtime.mediaTimeline.status.LastRestoreAckMS = ackMS diff --git a/Tools/Controller/internal/control/media_timeline_test.go b/Tools/Controller/internal/control/media_timeline_test.go index 62e6ab58..4f003ac1 100644 --- a/Tools/Controller/internal/control/media_timeline_test.go +++ b/Tools/Controller/internal/control/media_timeline_test.go @@ -13,6 +13,19 @@ import ( "time" ) +func TestMediaTimelineRestoreChecksElapsedBudgetAfterSuccessfulACK(t *testing.T) { + budget := 50 * time.Millisecond + if err := mediaTimelineRestoreAckError(nil, budget, budget); err != nil { + t.Fatal(err) + } + if err := mediaTimelineRestoreAckError(nil, budget+time.Nanosecond, budget); err == nil { + t.Fatal("late successful ACK bypassed restore deadline") + } + if err := mediaTimelineRestoreAckError(context.DeadlineExceeded, time.Millisecond, budget); !errors.Is(err, context.DeadlineExceeded) { + t.Fatalf("original request failure lost: %v", err) + } +} + func TestMediaTimelineBackgroundTrafficCannotBlockPreparedEdges(t *testing.T) { runtime, frames, mu := mediaTimelineFixture(t, false) value := armTimeline(t, runtime, 0) diff --git a/Tools/Controller/internal/ipcjson/cpu_profile.go b/Tools/Controller/internal/ipcjson/cpu_profile.go index 07e23126..9a546974 100644 --- a/Tools/Controller/internal/ipcjson/cpu_profile.go +++ b/Tools/Controller/internal/ipcjson/cpu_profile.go @@ -2,9 +2,11 @@ package ipcjson import ( "bytes" + "io" "net" "net/http" "runtime/pprof" + "runtime/trace" "sync/atomic" "time" ) @@ -14,6 +16,15 @@ var cpuProfileBusy atomic.Bool // A bounded, explicitly requested application profile, never an OS profiler // or a public pprof/debug listener. No files, configuration or board commands. func (service *Service) serveCPUProfile(writer http.ResponseWriter, request *http.Request) { + service.serveApplicationProfile(writer, request, pprof.StartCPUProfile, pprof.StopCPUProfile, "controller-cpu.pprof") +} + +func (service *Service) serveExecutionTrace(writer http.ResponseWriter, request *http.Request) { + service.serveApplicationProfile(writer, request, trace.Start, trace.Stop, "controller-execution.trace") +} + +// CPU and execution captures share exclusion and the same five-second bound. +func (service *Service) serveApplicationProfile(writer http.ResponseWriter, request *http.Request, start func(io.Writer) error, stop func(), filename string) { if request.Method != http.MethodPost { writer.Header().Set("Allow", http.MethodPost) writeHTTPJSON(writer, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"}) @@ -37,7 +48,7 @@ func (service *Service) serveCPUProfile(writer http.ResponseWriter, request *htt } defer cpuProfileBusy.Store(false) var profile bytes.Buffer - if err := pprof.StartCPUProfile(&profile); err != nil { + if err := start(&profile); err != nil { writeHTTPJSON(writer, http.StatusConflict, map[string]string{"error": "application profiler unavailable"}) return } @@ -47,9 +58,9 @@ func (service *Service) serveCPUProfile(writer http.ResponseWriter, request *htt case <-timer.C: case <-request.Context().Done(): } - pprof.StopCPUProfile() + stop() writer.Header().Set("Content-Type", "application/octet-stream") writer.Header().Set("Cache-Control", "no-store") - writer.Header().Set("Content-Disposition", "attachment; filename=controller-cpu.pprof") + writer.Header().Set("Content-Disposition", "attachment; filename="+filename) _, _ = writer.Write(profile.Bytes()) } diff --git a/Tools/Controller/internal/ipcjson/cpu_profile_test.go b/Tools/Controller/internal/ipcjson/cpu_profile_test.go index e8995fe3..59bb4136 100644 --- a/Tools/Controller/internal/ipcjson/cpu_profile_test.go +++ b/Tools/Controller/internal/ipcjson/cpu_profile_test.go @@ -1,11 +1,31 @@ package ipcjson import ( + "context" + "io" "net/http" "net/http/httptest" "testing" ) +func TestApplicationProfileCancellationStopsCaptureAndReleasesOwnership(t *testing.T) { + service, _ := browserUIConfigTestService(t) + ctx, cancel := context.WithCancel(context.Background()) + cancel() + request := httptest.NewRequest(http.MethodPost, "/api/diagnostics/execution-trace", nil).WithContext(ctx) + request.RemoteAddr = "127.0.0.1:12000" + started, stopped := false, false + response := httptest.NewRecorder() + service.serveApplicationProfile(response, request, func(w io.Writer) error { + started = true + _, err := w.Write([]byte("trace fixture")) + return err + }, func() { stopped = true }, "controller-execution.trace") + if response.Code != http.StatusOK || !started || !stopped || cpuProfileBusy.Load() { + t.Fatalf("capture ownership leaked: status=%d started=%v stopped=%v busy=%v", response.Code, started, stopped, cpuProfileBusy.Load()) + } +} + func TestCPUProfileRejectsNonlocalAndCrossOriginRequests(t *testing.T) { service, _ := browserUIConfigTestService(t) for _, test := range []struct { @@ -17,13 +37,15 @@ func TestCPUProfileRejectsNonlocalAndCrossOriginRequests(t *testing.T) { {"127.0.0.1:12000", "https://hostile.example", http.MethodPost, http.StatusForbidden}, {"[::1]:12000", "", http.MethodGet, http.StatusMethodNotAllowed}, } { - request := httptest.NewRequest(test.method, "/api/diagnostics/cpu-profile", nil) - request.RemoteAddr = test.address - request.Header.Set("Origin", test.origin) - response := httptest.NewRecorder() - service.serveCPUProfile(response, request) - if response.Code != test.status { - t.Fatalf("%+v returned %d", test, response.Code) + for _, handler := range []func(http.ResponseWriter, *http.Request){service.serveCPUProfile, service.serveExecutionTrace} { + request := httptest.NewRequest(test.method, "/api/diagnostics/cpu-profile", nil) + request.RemoteAddr = test.address + request.Header.Set("Origin", test.origin) + response := httptest.NewRecorder() + handler(response, request) + if response.Code != test.status { + t.Fatalf("%+v returned %d", test, response.Code) + } } } } diff --git a/Tools/Controller/internal/ipcjson/ipc.go b/Tools/Controller/internal/ipcjson/ipc.go index 8fce314c..92e17bb4 100644 --- a/Tools/Controller/internal/ipcjson/ipc.go +++ b/Tools/Controller/internal/ipcjson/ipc.go @@ -1255,10 +1255,16 @@ func (service *Service) dispatch( case "controller.rf.catalog": result, err = service.rfCatalog(ctx, request.Params) case "controller.rf.observed.add": - var params struct { Code uint32 `json:"code"`; Bits byte `json:"bits"`; Protocol byte `json:"protocol"` } - if err = decodeStrictParams(request.Params,¶ms); err == nil { - err = service.Client.AddObservedRF(ctx,params.Code,params.Bits,params.Protocol) - if err == nil { result,err = service.Client.ListLearnedDetailed(ctx) } + var params struct { + Code uint32 `json:"code"` + Bits byte `json:"bits"` + Protocol byte `json:"protocol"` + } + if err = decodeStrictParams(request.Params, ¶ms); err == nil { + err = service.Client.AddObservedRF(ctx, params.Code, params.Bits, params.Protocol) + if err == nil { + result, err = service.Client.ListLearnedDetailed(ctx) + } } case "controller.rf.binding.put": result, err = service.putRFBinding(request.Params) @@ -3118,6 +3124,7 @@ func websocketMux(serverContext context.Context, service *Service) http.Handler socketIOPath := service.currentSocketIOPath() mux := http.NewServeMux() mux.HandleFunc("/api/diagnostics/cpu-profile", service.serveCPUProfile) + mux.HandleFunc("/api/diagnostics/execution-trace", service.serveExecutionTrace) mux.HandleFunc(webSocketPath, func(writer http.ResponseWriter, request *http.Request) { if !authorizeHTTPRequest(writer, request, service) { return diff --git a/docs/STRIP-WORKSPACE-INTEGRATION.md b/docs/STRIP-WORKSPACE-INTEGRATION.md index e8396fdc..0f10aaaf 100644 --- a/docs/STRIP-WORKSPACE-INTEGRATION.md +++ b/docs/STRIP-WORKSPACE-INTEGRATION.md @@ -89,3 +89,14 @@ recognizes only the typed serial-channel/media-timeline deferral: it advances its polling cadence without emitting an error or a fabricated fresh observation. Other failures remain visible. Competing output commands retain the same typed ownership error, and automatic LCD presentation reports deferral honestly. + +The ci.1651 replay emitted no false background errors, but missed the first +strip configure dispatch by 54.1 ms after a 61.1 ms executor iteration gap. It +refused the expired command, then paused with outputs stopped and unchanged +serial/reset counters. A paused CPU profile did not establish the cause. +`POST /api/diagnostics/execution-trace` now offers the same local-only, +authenticated, body-free five-second capture contract as the CPU profiler; +captures share exclusion and remain private diagnostic data. It is intended to +separate runnable delay, blocking and GC in one coordinated replay. Restore ACKs +also receive a post-response elapsed-budget check, because delayed timer +callbacks must not admit an acknowledgement that exceeded its original budget. From 4c17b973752c75a35839e3c2c82d7b2fc8026d25 Mon Sep 17 00:00:00 2001 From: DRSDavidSoft <4673812+DRSDavidSoft@users.noreply.github.com> Date: Tue, 27 Oct 2026 03:07:28 +0330 Subject: [PATCH 11/19] Document bounded execution trace in API catalog --- Tools/Audit/generate-api-reference.mjs | 1 + Tools/Controller/api/openapi.json | 47 ++++++++++++++++++++++++++ Tools/Controller/api/reference.html | 6 ++-- 3 files changed, 51 insertions(+), 3 deletions(-) diff --git a/Tools/Audit/generate-api-reference.mjs b/Tools/Audit/generate-api-reference.mjs index 16b68f2e..99f08450 100644 --- a/Tools/Audit/generate-api-reference.mjs +++ b/Tools/Audit/generate-api-reference.mjs @@ -293,6 +293,7 @@ const routes = [ { path: "/api/rpc", methods: ["post"], capability: "dynamic", summary: "JSON-RPC 2.0 request" }, { path: "/api/snapshot", methods: ["get"], capability: "read", summary: "Authoritative cached controller snapshot" }, { path: "/api/diagnostics/cpu-profile", methods: ["post"], capability: "local-only", binaryResponse: true, summary: "Explicit five-second application CPU profile; loopback callers only, one active capture, no board actions or request body" }, + { path: "/api/diagnostics/execution-trace", methods: ["post"], capability: "local-only", binaryResponse: true, summary: "Explicit five-second Go execution trace; loopback callers only, shared profile capture exclusion, no board actions or request body" }, { path: "/api/estop", methods: ["get"], capability: "read", summary: "Authoritative effect and motion interlock state" }, { path: "/api/estop", methods: ["put", "post"], capability: "board_commands", summary: "Engage or release the effect and motion interlock" }, { path: "/api/peripherals", methods: ["get"], capability: "read", summary: "Peripheral descriptors and host-owned names" }, diff --git a/Tools/Controller/api/openapi.json b/Tools/Controller/api/openapi.json index 437e2201..c99cd03d 100644 --- a/Tools/Controller/api/openapi.json +++ b/Tools/Controller/api/openapi.json @@ -334,6 +334,53 @@ } } }, + "/api/diagnostics/execution-trace": { + "post": { + "operationId": "post_api_diagnostics_execution_trace", + "summary": "Explicit five-second Go execution trace; loopback callers only, shared profile capture exclusion, no board actions or request body", + "tags": [ + "execution-trace" + ], + "x-required-capability": "local-only", + "x-idempotency": "see operation semantics and idempotency keys", + "responses": { + "200": { + "description": "Five-second gzip-encoded Go CPU profile", + "content": { + "application/octet-stream": { + "schema": { + "type": "string", + "format": "binary" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "405": { + "$ref": "#/components/responses/MethodNotAllowed" + } + }, + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + } + } + }, "/api/estop": { "get": { "operationId": "get_api_estop", diff --git a/Tools/Controller/api/reference.html b/Tools/Controller/api/reference.html index 02e2d4a4..aa1c8fb7 100644 --- a/Tools/Controller/api/reference.html +++ b/Tools/Controller/api/reference.html @@ -2,7 +2,7 @@ PCController API Reference -
OFFLINE CONTRACT · LIVING API

PCController API

The primary host exposes one unversioned, safety-gated living surface across REST, JSON-RPC, WebSocket, and the bounded Socket.IO adapter. Loopback is the default; immediate-alpha application authentication and authorization are disabled under issue #148.

OpenAPI 3.1AsyncAPI 3.0JSON-RPC schema189 RPC methods88 HTTP operations
-

HTTP operations

Canonical routes live directly under /api/. JSON bodies are capped at 1 MiB.

MethodPathPurposeCapability
GET/healthzService liveness and API identitypublic
GET/upnp/public.jsonBounded public host, board, endpoint, health, and telemetry directorypublic
GET/api/ui-configNon-secret browser bootstrappublic
GET/api/auth/server-proofDormant alpha compatibility endpoint; returns 409 while application authentication is disabledpublic
POST/api/session/ticketDormant alpha compatibility endpoint; returns 409 while application authentication is disabledsession
POST/api/rpcJSON-RPC 2.0 requestdynamic
GET/api/snapshotAuthoritative cached controller snapshotread
POST/api/diagnostics/cpu-profileExplicit five-second application CPU profile; loopback callers only, one active capture, no board actions or request bodylocal-only
GET/api/estopAuthoritative effect and motion interlock stateread
PUT/api/estopEngage or release the effect and motion interlockboard_commands
POST/api/estopEngage or release the effect and motion interlockboard_commands
GET/api/peripheralsPeripheral descriptors and host-owned namesread
PUT/api/peripheralsReplace host-owned peripheral nameshost_configuration
GET/api/pwmAuthoritative PWM stateread
PUT/api/pwmMutate PWM state with board readbackboard_commands
DELETE/api/pwmMutate PWM state with board readbackboard_commands
GET/api/commandsShared command catalogread
GET/api/program-stateHost-owned program stateread
PUT/api/program-stateUpdate host-owned program stateboard_commands
POST/api/program-stateUpdate host-owned program stateboard_commands
GET/api/menu/catalogLive board menu catalogread
GET/api/menu/layoutLive board menu layoutread
PUT/api/menu/layoutPersist a validated board menu layoutboard_commands
POST/api/menu/layoutPersist a validated board menu layoutboard_commands
GET/api/host-menusHost-presented menu directoryread
PUT/api/host-menusReplace the host-presented menu directoryhost_configuration
POST/api/host-menusReplace the host-presented menu directoryhost_configuration
GET/api/os/statusHost status and policyread
GET/api/os/factsBounded read-only host factsread
POST/api/os/keyValidated virtual-key actionvirtual_keys
POST/api/os/powerConfirmed system or display actionpower_actions
POST/api/commandShared command dispatcherdynamic
POST/api/messagesTyped source-tagged messagemessages
POST/api/displayPresent arbitrary seven-segment or LCD text with scroll and repeat timingboard_commands
POST/api/opcodeExchange an opaque versionless UART opcodeboard_commands
GET/api/app/bridgeOriginal coordinator bridge instance and process identityread
GET/api/app/instancesList or query live application instancesread
POST/api/app/instancesReport or remove one live application instancehost_configuration
DELETE/api/app/instancesReport or remove one live application instancehost_configuration
POST/api/app/actionFreeze and route a correlated action to exact live application instanceshost_configuration
POST/api/app/action/ackAcknowledge one exact app-action target outcomehost_configuration
GET/api/app/action/outcomeRead one bounded app-action operation outcomeread
POST/api/app/navigateNavigate all, one surface, or one exact application instancehost_configuration
POST/api/app/launchEnsure, launch, or focus a named TUI or WebUI surfacehost_configuration
GET/api/bridgesConfigured bridge stateread
POST/api/bridges/callCorrelated bridge callbridge_calls
GET/api/artifacts/manifestArtifact/default/current manifestread
GET/api/artifactsContent-addressed artifact catalogread
POST/api/artifacts/uploadImport a verified artifactprogramming
POST/api/artifacts/fetchQueue a bounded remote artifact fetchprogramming
POST/api/artifacts/captureCapture flash or EEPROM explicitlyprogramming
GET/api/artifacts/{kind}/{sha256}Immutable ranged artifact downloadread
HEAD/api/artifacts/{kind}/{sha256}Immutable ranged artifact downloadread
GET/api/artifacts/current/{kind}Latest explicitly captured artifactread
HEAD/api/artifacts/current/{kind}Latest explicitly captured artifactread
POST/api/board/firmware/uploadQueue a guarded staged-firmware transfer to the boardprogramming
GET/api/board/firmware/downloadDownload the latest verified board flash readbackread
HEAD/api/board/firmware/downloadDownload the latest verified board flash readbackread
POST/api/board/firmware/downloadQueue a fresh verified board flash readbackprogramming
POST/api/updates/{kind}Queue an explicitly authorized updateprogramming
POST/api/restores/flashQueue an explicitly authorized flash restoreprogramming
GET/api/updates/status/{id}Update operation progressread
POST/api/discovery/github/workflowDiscover workflow artifactsread
POST/api/discovery/github/releaseDiscover release assetsread
GET/api/discovery/manifestServe or validate an update manifestread
POST/api/discovery/manifestServe or validate an update manifestread
POST/api/discovery/checkCompare candidate and installed identitiesread
POST/api/discovery/stageStage a verified candidate without programmingprogramming
GET/api/discovery/status/{id}Discovery or staging progressread
POST/api/webhooks/inboundOptional inbound typed webhookmessages
GET/api/webhooks/outbound/statusOutbound delivery queue and dead-letter statusread
GET/api/webhooks/outbound/pendingBounded non-secret pending delivery listread
GET/api/webhooks/outbound/deadBounded non-secret dead-letter listread
POST/api/webhooks/outbound/replayReplay explicitly selected dead-letter deliveriesintegrations
POST/api/webhooks/outbound/clearClear explicitly selected dead-letter deliveriesintegrations
GET/api/integrations/statusRequested and effective buzzer routing and playback stateread
GET/api/integrations/datahub/{path}Sanitized loopback data-service proxyintegrations
HEAD/api/integrations/datahub/{path}Sanitized loopback data-service proxyintegrations
POST/api/integrations/datahub/{path}Sanitized loopback data-service proxyintegrations
PUT/api/integrations/datahub/{path}Sanitized loopback data-service proxyintegrations
PATCH/api/integrations/datahub/{path}Sanitized loopback data-service proxyintegrations
DELETE/api/integrations/datahub/{path}Sanitized loopback data-service proxyintegrations
GET/api/integrations/device/{path}Fail-closed device route; use typed RPCintegrations
HEAD/api/integrations/device/{path}Fail-closed device route; use typed RPCintegrations
POST/api/integrations/device/{path}Fail-closed device route; use typed RPCintegrations
PUT/api/integrations/device/{path}Fail-closed device route; use typed RPCintegrations
PATCH/api/integrations/device/{path}Fail-closed device route; use typed RPCintegrations
DELETE/api/integrations/device/{path}Fail-closed device route; use typed RPCintegrations
+
OFFLINE CONTRACT · LIVING API

PCController API

The primary host exposes one unversioned, safety-gated living surface across REST, JSON-RPC, WebSocket, and the bounded Socket.IO adapter. Loopback is the default; immediate-alpha application authentication and authorization are disabled under issue #148.

OpenAPI 3.1AsyncAPI 3.0JSON-RPC schema189 RPC methods89 HTTP operations
+

HTTP operations

Canonical routes live directly under /api/. JSON bodies are capped at 1 MiB.

MethodPathPurposeCapability
GET/healthzService liveness and API identitypublic
GET/upnp/public.jsonBounded public host, board, endpoint, health, and telemetry directorypublic
GET/api/ui-configNon-secret browser bootstrappublic
GET/api/auth/server-proofDormant alpha compatibility endpoint; returns 409 while application authentication is disabledpublic
POST/api/session/ticketDormant alpha compatibility endpoint; returns 409 while application authentication is disabledsession
POST/api/rpcJSON-RPC 2.0 requestdynamic
GET/api/snapshotAuthoritative cached controller snapshotread
POST/api/diagnostics/cpu-profileExplicit five-second application CPU profile; loopback callers only, one active capture, no board actions or request bodylocal-only
POST/api/diagnostics/execution-traceExplicit five-second Go execution trace; loopback callers only, shared profile capture exclusion, no board actions or request bodylocal-only
GET/api/estopAuthoritative effect and motion interlock stateread
PUT/api/estopEngage or release the effect and motion interlockboard_commands
POST/api/estopEngage or release the effect and motion interlockboard_commands
GET/api/peripheralsPeripheral descriptors and host-owned namesread
PUT/api/peripheralsReplace host-owned peripheral nameshost_configuration
GET/api/pwmAuthoritative PWM stateread
PUT/api/pwmMutate PWM state with board readbackboard_commands
DELETE/api/pwmMutate PWM state with board readbackboard_commands
GET/api/commandsShared command catalogread
GET/api/program-stateHost-owned program stateread
PUT/api/program-stateUpdate host-owned program stateboard_commands
POST/api/program-stateUpdate host-owned program stateboard_commands
GET/api/menu/catalogLive board menu catalogread
GET/api/menu/layoutLive board menu layoutread
PUT/api/menu/layoutPersist a validated board menu layoutboard_commands
POST/api/menu/layoutPersist a validated board menu layoutboard_commands
GET/api/host-menusHost-presented menu directoryread
PUT/api/host-menusReplace the host-presented menu directoryhost_configuration
POST/api/host-menusReplace the host-presented menu directoryhost_configuration
GET/api/os/statusHost status and policyread
GET/api/os/factsBounded read-only host factsread
POST/api/os/keyValidated virtual-key actionvirtual_keys
POST/api/os/powerConfirmed system or display actionpower_actions
POST/api/commandShared command dispatcherdynamic
POST/api/messagesTyped source-tagged messagemessages
POST/api/displayPresent arbitrary seven-segment or LCD text with scroll and repeat timingboard_commands
POST/api/opcodeExchange an opaque versionless UART opcodeboard_commands
GET/api/app/bridgeOriginal coordinator bridge instance and process identityread
GET/api/app/instancesList or query live application instancesread
POST/api/app/instancesReport or remove one live application instancehost_configuration
DELETE/api/app/instancesReport or remove one live application instancehost_configuration
POST/api/app/actionFreeze and route a correlated action to exact live application instanceshost_configuration
POST/api/app/action/ackAcknowledge one exact app-action target outcomehost_configuration
GET/api/app/action/outcomeRead one bounded app-action operation outcomeread
POST/api/app/navigateNavigate all, one surface, or one exact application instancehost_configuration
POST/api/app/launchEnsure, launch, or focus a named TUI or WebUI surfacehost_configuration
GET/api/bridgesConfigured bridge stateread
POST/api/bridges/callCorrelated bridge callbridge_calls
GET/api/artifacts/manifestArtifact/default/current manifestread
GET/api/artifactsContent-addressed artifact catalogread
POST/api/artifacts/uploadImport a verified artifactprogramming
POST/api/artifacts/fetchQueue a bounded remote artifact fetchprogramming
POST/api/artifacts/captureCapture flash or EEPROM explicitlyprogramming
GET/api/artifacts/{kind}/{sha256}Immutable ranged artifact downloadread
HEAD/api/artifacts/{kind}/{sha256}Immutable ranged artifact downloadread
GET/api/artifacts/current/{kind}Latest explicitly captured artifactread
HEAD/api/artifacts/current/{kind}Latest explicitly captured artifactread
POST/api/board/firmware/uploadQueue a guarded staged-firmware transfer to the boardprogramming
GET/api/board/firmware/downloadDownload the latest verified board flash readbackread
HEAD/api/board/firmware/downloadDownload the latest verified board flash readbackread
POST/api/board/firmware/downloadQueue a fresh verified board flash readbackprogramming
POST/api/updates/{kind}Queue an explicitly authorized updateprogramming
POST/api/restores/flashQueue an explicitly authorized flash restoreprogramming
GET/api/updates/status/{id}Update operation progressread
POST/api/discovery/github/workflowDiscover workflow artifactsread
POST/api/discovery/github/releaseDiscover release assetsread
GET/api/discovery/manifestServe or validate an update manifestread
POST/api/discovery/manifestServe or validate an update manifestread
POST/api/discovery/checkCompare candidate and installed identitiesread
POST/api/discovery/stageStage a verified candidate without programmingprogramming
GET/api/discovery/status/{id}Discovery or staging progressread
POST/api/webhooks/inboundOptional inbound typed webhookmessages
GET/api/webhooks/outbound/statusOutbound delivery queue and dead-letter statusread
GET/api/webhooks/outbound/pendingBounded non-secret pending delivery listread
GET/api/webhooks/outbound/deadBounded non-secret dead-letter listread
POST/api/webhooks/outbound/replayReplay explicitly selected dead-letter deliveriesintegrations
POST/api/webhooks/outbound/clearClear explicitly selected dead-letter deliveriesintegrations
GET/api/integrations/statusRequested and effective buzzer routing and playback stateread
GET/api/integrations/datahub/{path}Sanitized loopback data-service proxyintegrations
HEAD/api/integrations/datahub/{path}Sanitized loopback data-service proxyintegrations
POST/api/integrations/datahub/{path}Sanitized loopback data-service proxyintegrations
PUT/api/integrations/datahub/{path}Sanitized loopback data-service proxyintegrations
PATCH/api/integrations/datahub/{path}Sanitized loopback data-service proxyintegrations
DELETE/api/integrations/datahub/{path}Sanitized loopback data-service proxyintegrations
GET/api/integrations/device/{path}Fail-closed device route; use typed RPCintegrations
HEAD/api/integrations/device/{path}Fail-closed device route; use typed RPCintegrations
POST/api/integrations/device/{path}Fail-closed device route; use typed RPCintegrations
PUT/api/integrations/device/{path}Fail-closed device route; use typed RPCintegrations
PATCH/api/integrations/device/{path}Fail-closed device route; use typed RPCintegrations
DELETE/api/integrations/device/{path}Fail-closed device route; use typed RPCintegrations

JSON-RPC methods

Standard JSON-RPC errors are preserved; host extensions use -32001 for authentication, -32003 for capability denial, and -32000 for runtime or device failures.

MethodPurposeCapabilityIdempotency
controller.action.invokeInvoke one stable action ID advertised by the attached board profile and wait for the board acknowledgement.board_commandsnon-idempotent
controller.app.actionFreeze a validated action to exact live targets and return its correlated per-target delivery operation.dynamicnon-idempotent
controller.app.action.ackAcknowledge one exact app-action target as applied or rejected.host_configurationidempotent-with-authoritative-readback
controller.app.action.outcomeReturn one bounded app-action operation and its exact per-target outcomes.readsafe
controller.app.bridgeReturn the original coordinator bridge instance and its bounded process self-information.readsafe
controller.app.instance.getRead one live application instance by ID.readsafe
controller.app.instance.removeRemove one application instance from the live registry.host_configurationnon-idempotent
controller.app.instance.reportCreate or refresh one leased application-instance report.host_configurationnon-idempotent
controller.app.instancesList live application instances and their bounded non-secret state.readsafe
controller.app.launchEnsure, launch, or focus a named TUI or WebUI surface without arbitrary process execution.host_configurationidempotent-with-authoritative-readback
controller.app.navigateNavigate all, one surface, or one exact live application instance.host_configurationnon-idempotent
controller.app.navigation.commitCommit one follower group's canonical page and return its correlated coordinator outcome.host_configurationnon-idempotent
controller.app.pageapp pagehost_configurationnon-idempotent
controller.artifact.captureartifact captureprogrammingnon-idempotent
controller.artifact.fetchartifact fetchprogrammingnon-idempotent
controller.artifact.listartifact listreadsafe
controller.artifact.manifestartifact manifestreadsafe
controller.artifact.uploadartifact uploadprogrammingidempotent-with-authoritative-readback
controller.artifact.upload.abortAbort and remove an incomplete peer artifact transfer.programmingnon-idempotent
controller.artifact.upload.beginBegin a bounded correlated peer artifact transfer.programmingnon-idempotent
controller.artifact.upload.chunkAppend one ordered bounded chunk to a peer artifact transfer.programmingnon-idempotent
controller.artifact.upload.finishRevalidate and publish a completed peer artifact transfer.programmingnon-idempotent
controller.audio.cuesReturn the receiving host's declared local sound-effect intent vocabulary.readsafe
controller.audio.playPlay one validated sound-effect intent on the receiving host while honoring its mute and volume policy.integrationsnon-idempotent
controller.board_automation.clearClear the board automation table and verify the empty snapshot.board_commandsidempotent-with-authoritative-readback
controller.board_automation.listRead one generation-consistent board automation snapshot.readsafe
controller.board_automation.putTransactionally add or edit one board automation and verify readback.board_commandsnon-idempotent
controller.board_automation.removeRemove one board automation and verify its absence.board_commandsidempotent-with-authoritative-readback
controller.board_profile.getReturn the attached board identity, configured wiring mode, and opaque profile revision.readsafe
controller.board_profile.updateBind the attached physical board identity to one explicit host-owned wiring profile.host_configurationidempotent-with-authoritative-readback
controller.board.firmware.downloadQueue a fresh verified flash-only readback from the board.programmingnon-idempotent
controller.board.firmware.uploadQueue a guarded transfer of a staged immutable firmware artifact to the board.programmingnon-idempotent
controller.bridge.callbridge callbridge_callsnon-idempotent
controller.bridge.listbridge listreadsafe
controller.closecloseconnection_controlidempotent-with-authoritative-readback
controller.command.catalogReturn the machine-readable shared command catalog.readsafe
controller.command.executeRun a shared command after semantic capability classification.dynamicnon-idempotent
controller.connectconnectconnection_controlidempotent-with-authoritative-readback
controller.device.actiondevice actionintegrationsnon-idempotent
controller.device.inspectdevice inspectintegrationsidempotent-with-authoritative-readback
controller.device.statusdevice statusintegrationsidempotent-with-authoritative-readback
controller.discovery.checkdiscovery checkreadsafe
controller.discovery.configReturn persistent network advertisement configuration.readsafe
controller.discovery.config.getReturn persistent network advertisement configuration.readsafe
controller.discovery.config.setPersist and hot-apply network advertisement configuration.integrationsidempotent-with-authoritative-readback
controller.discovery.connectVerify an authenticated discovered host and return its health and snapshot.bridge_callsidempotent-with-authoritative-readback
controller.discovery.github.releasediscovery github releasereadsafe
controller.discovery.github.workflowdiscovery github workflowreadsafe
controller.discovery.local_manifestdiscovery local manifestreadsafe
controller.discovery.manifestdiscovery manifestreadsafe
controller.discovery.scandiscovery scanreadsafe
controller.discovery.stagediscovery stageprogrammingnon-idempotent
controller.discovery.statusdiscovery statusreadsafe
controller.display.senddisplay sendboard_commandsnon-idempotent
controller.estop.getReturn the authoritative PCController-wide effect and motion interlock state.readsafe
controller.estop.setEngage or release the PCController-wide effect and motion interlock; release never resumes cancelled work.board_commandsidempotent-with-authoritative-readback
controller.event.latestevent latesteventssafe
controller.event.nextLong-poll the next retained event after an event ID, optionally selecting activity, state, telemetry, or debug.eventssafe
controller.exitexitshutdownidempotent-with-authoritative-readback
controller.firmware.buildCompile the canonical host project with reviewed feature names and publish correlated ordered program progress events.programmingnon-idempotent
controller.front_panelfront panelreadsafe
controller.front-panelfront panelreadsafe
controller.history.statushistory statusreadsafe
controller.history.timelinehistory timelinereadsafe
controller.host_menu.confighost menu configreadsafe
controller.host_menu.config.gethost menu config getreadsafe
controller.host_menu.config.sethost menu config sethost_configurationidempotent-with-authoritative-readback
controller.host_menu.configurehost menu configurehost_configurationidempotent-with-authoritative-readback
controller.host_menu.content.pushhost menu content pushboard_commandsidempotent-with-authoritative-readback
controller.host_menu.directory.replacehost menu directory replaceboard_commandsidempotent-with-authoritative-readback
controller.host_menu.statehost menu statereadsafe
controller.host.factshost factsreadsafe
controller.host.facts.cataloghost facts catalogreadsafe
controller.hotkeys.gethotkeys getreadsafe
controller.hotkeys.sethotkeys sethost_configurationidempotent-with-authoritative-readback
controller.illumination.getRead persisted enclosure-light policy, live door-selected target, and exact applied MOSFET channel-12 PWM.readsafe
controller.illumination.overrideTemporarily apply exact 0..4095 PWM to MOSFET channel 12 without changing or taking ownership from the enclosure-light policy, then return authoritative readback.board_commandsidempotent-with-authoritative-readback
controller.illumination.setChange only mode/on/off illumination fields, verify EEPROM durability, and return authoritative live state.board_commandsidempotent-with-authoritative-readback
controller.integrations.local.getintegrations local getintegrationsidempotent-with-authoritative-readback
controller.integrations.local.setintegrations local setintegrationsidempotent-with-authoritative-readback
controller.integrations.statusReturn requested and effective buzzer routing and playback state.readsafe
controller.lcd.presentation.configurelcd presentation configurehost_configurationidempotent-with-authoritative-readback
controller.lcd.presentation.statuslcd presentation statusreadsafe
controller.lcd.prioritylcd priorityboard_commandsnon-idempotent
controller.lcd.promptlcd promptboard_commandsnon-idempotent
controller.media.authority.changeRequest, accept, reject, release, lock, or unlock playback authority for a registered client.board_commandsidempotent-with-authoritative-readback
controller.media.authority.getReturn the registered playback authority owner, pending requests, exclusivity, and revision.readsafe
controller.media.playback.getReturn the authoritative registered consumer playback clock and prepared-timeline state.readsafe
controller.media.playback.updatePublish one sequenced playback-clock update from the registered authority owner.board_commandsidempotent-with-authoritative-readback
controller.media.timeline.getReturn the prepared media timeline, acknowledgement ledger, and sticky failure state.readsafe
controller.media.timeline.prepareValidate and arm a revisioned host-executed hardware timeline for a registered paused consumer.board_commandsidempotent-with-authoritative-readback
controller.melodies.listReturn the effective configured host melody catalog.readsafe
controller.menu.currentmenu currentreadsafe
controller.menu.jumpmenu jumpboard_commandsidempotent-with-authoritative-readback
controller.menu.layout.getmenu layout getreadsafe
controller.menu.layout.setmenu layout setboard_commandsidempotent-with-authoritative-readback
controller.menu.listmenu listreadsafe
controller.menu.pagemenu pageboard_commandsidempotent-with-authoritative-readback
controller.message.sendmessage sendmessagesnon-idempotent
controller.mirror.applymirror applyboard_commandsidempotent-with-authoritative-readback
controller.mirror.configuremirror configurehost_configurationidempotent-with-authoritative-readback
controller.mirror.inspectmirror inspectreadsafe
controller.mirror.resyncmirror resyncboard_commandsidempotent-with-authoritative-readback
controller.mirror.statusmirror statusreadsafe
controller.network.peers.getReturn persistent peer topology with secret references and no plaintext credentials.readsafe
controller.network.peers.setReplace and hot-apply peer topology; events, state, and status topics are accepted, and only optional secret references may carry compatibility credentials.host_configurationidempotent-with-authoritative-readback
controller.opcode.exchangeExchange an opaque versionless UART opcode and caller-selected response opcode.board_commandsnon-idempotent
controller.opcode.requestAlias for an opaque versionless UART opcode exchange.board_commandsnon-idempotent
controller.opcode.sendSend an opaque versionless UART opcode; ACK is expected by default.board_commandsnon-idempotent
controller.openopenconnection_controlidempotent-with-authoritative-readback
controller.os.configureos configurehost_configurationidempotent-with-authoritative-readback
controller.os.factsos factsreadsafe
controller.os.facts.catalogos facts catalogreadsafe
controller.os.keyos keyvirtual_keysnon-idempotent
controller.os.policyos policyreadsafe
controller.os.poweros powerpower_actionsnon-idempotent
controller.os.statusos statusreadsafe
controller.peer.update.hostTransfer a verified executable using a caller-generated idempotency_key, wait through the peer restart, and verify its exact active SHA-256 acknowledgement. Retry uncertain outcomes with the same key.programmingnon-idempotent
controller.peripheral.folder.updateAtomically create, rename, set the icon, delete or move members of a board-profile channel folder using required expected_revision; return the complete authoritative folder/control catalog.host_configurationidempotent-with-authoritative-readback
controller.peripheral.presentation.updateUpdate one advertised peripheral name, icon, group, kind-local order, visibility, or lock using optional optimistic concurrency.host_configurationidempotent-with-authoritative-readback
controller.peripheralsperipheralsreadsafe
controller.peripherals.getperipherals getreadsafe
controller.peripherals.setperipherals sethost_configurationidempotent-with-authoritative-readback
controller.pingReturn service health and protocol identity.readsafe
controller.port.closeport closeconnection_controlidempotent-with-authoritative-readback
controller.port.openport openconnection_controlidempotent-with-authoritative-readback
controller.port.ownerport ownerreadsafe
controller.port.processport processreadsafe
controller.port.resetport resetresetnon-idempotent
controller.portsportsreadsafe
controller.program_state.getprogram state getreadsafe
controller.program_state.setprogram state setboard_commandsidempotent-with-authoritative-readback
controller.program-state.getprogram state getreadsafe
controller.program-state.setprogram state setboard_commandsidempotent-with-authoritative-readback
controller.pwm.channel.configurePersist a partial PWM channel configuration (output type, icon, curve, gamma, step_percent, on_behavior full|restore), preserving omitted fields, then publish the changed descriptor.host_configurationidempotent-with-authoritative-readback
controller.pwm.offpwm offboard_commandsidempotent-with-authoritative-readback
controller.pwm.setpwm setboard_commandsidempotent-with-authoritative-readback
controller.pwm.trace.getReturn bounded DEBUG-only PWM acknowledgements; no board read or output change.readsafe
controller.pwm.trace.startTemporarily capture one channel 0..15 for explicit duration_ms 1..60000, off by default; no board operation.eventssafe
controller.pwm.trace.stopStop temporary PWM acknowledgement capture and retain bounded receipts; no board operation.eventssafe
controller.pwm.valuespwm valuesreadsafe
controller.quitquitshutdownidempotent-with-authoritative-readback
controller.resetresetresetnon-idempotent
controller.reset.linesreset linesresetnon-idempotent
controller.restore.flashRestore a captured flash backup through the guarded restore path.programmingnon-idempotent
controller.rf.binding.putCreate a uniquely named validated RF binding, or explicitly edit with previous_name; reject name/stale-target conflicts without replacing unrelated assignments.host_configurationidempotent-with-authoritative-readback
controller.rf.binding.removeRemove one named host-owned RF application binding.host_configurationidempotent-with-authoritative-readback
controller.rf.catalogReturn learned RF records, live capture state, persisted bindings, and currently advertised action choices.readsafe
controller.rf.clearrf clearboard_commandsidempotent-with-authoritative-readback
controller.rf.learn.cancelrf learn cancelboard_commandsidempotent-with-authoritative-readback
controller.rf.learn.startrf learn startboard_commandsnon-idempotent
controller.rf.learn.statusrf learn statusreadsafe
controller.rf.listrf listreadsafe
controller.rf.mapReplace one learned RF mapping and return board readback.board_commandsidempotent-with-authoritative-readback
controller.rf.observed.addSave a waveform actually received from the current board, unassigned, with preserved records and verified readback.board_commandsidempotent-with-authoritative-readback
controller.rf.preferences.setSave timed-learning, duration and continuous-capture preferences without entering learning.host_configurationidempotent-with-authoritative-readback
controller.rf.presentationrf presentationreadsafe
controller.rf.removerf removeboard_commandsidempotent-with-authoritative-readback
controller.rf.transmitTransmit one validated RF waveform request.board_commandsnon-idempotent
controller.session.snapshotsession snapshotreadsafe
controller.session.snapshot.lastsession snapshot lastreadsafe
controller.snapshotReturn the authoritative cached controller snapshot.readsafe
controller.statusstatusreadsafe
controller.status_led.releasestatus led releaseboard_commandsidempotent-with-authoritative-readback
controller.status_led.setstatus led setboard_commandsidempotent-with-authoritative-readback
controller.subscribeSubscribe this WebSocket connection to activity events, changed state, explicit debug, raw opcodes, and/or status.eventssafe
controller.system.statussystem statusreadsafe
controller.temperaturestemperaturesreadsafe
controller.udp.calludp callbridge_callsidempotent-with-authoritative-readback
controller.udp.config.getudp config getreadsafe
controller.udp.config.setudp config sethost_configurationidempotent-with-authoritative-readback
controller.udp.events.getudp events getreadsafe
controller.udp.statusudp statusreadsafe
controller.udp.subscribeudp subscribehost_configurationidempotent-with-authoritative-readback
controller.udp.unsubscribeudp unsubscribehost_configurationidempotent-with-authoritative-readback
controller.ui.configReturn host-owned UI settings, including status_interval_ms and measurement_freshness_ms.readsafe
controller.ui.config.getReturn host-owned UI settings, including status_interval_ms and measurement_freshness_ms.readsafe
controller.ui.config.setAtomically persist host UI fields; status_interval_ms is 50..60000 and measurement_freshness_ms is refresh+100..120000.host_configurationidempotent-with-authoritative-readback
controller.unsubscribeRemove this WebSocket connection's active subscriptions.eventssafe
controller.update.eepromupdate eepromprogrammingnon-idempotent
controller.update.firmwareupdate firmwareprogrammingnon-idempotent
controller.update.hostupdate hostprogrammingnon-idempotent
controller.update.statusupdate statusreadsafe
controller.virtual_keyvirtual keyvirtual_keysidempotent-with-authoritative-readback
controller.webhooks.clearClear explicitly selected dead-letter deliveries.integrationsidempotent-with-authoritative-readback
controller.webhooks.deadList bounded non-secret dead-letter deliveries.readsafe
controller.webhooks.pendingList bounded non-secret pending outbound deliveries.readsafe
controller.webhooks.replayReplay explicitly selected dead-letter deliveries.integrationsnon-idempotent
controller.webhooks.statusReturn bounded outbound queue and dead-letter counters.readsafe
-

Contract digest 37d7181d0d839dbe2aba78f731adc11b93378796c26478bbff0c5bd7cc6bf5fa. Generated by Tools/Audit/generate-api-reference.mjs.

+

Contract digest 993a4c468b4553a208793aa8744ea56ce46d8a3854d3bfd0f541b1d210c58785. Generated by Tools/Audit/generate-api-reference.mjs.

From e3710fc9a53db3b6fffafa6ca6b3acbbfdbd8867 Mon Sep 17 00:00:00 2001 From: DRSDavidSoft <4673812+DRSDavidSoft@users.noreply.github.com> Date: Tue, 27 Oct 2026 03:30:38 +0330 Subject: [PATCH 12/19] Avoid effect catalog expansion in live board observers --- Tools/Controller/controller.go | 27 +++++++++++++---- Tools/Controller/controller_test.go | 8 ++++- .../internal/control/media_timeline_test.go | 30 +++++++++++++++++++ .../internal/control/output_streams.go | 18 ++++++----- Tools/Controller/internal/control/runtime.go | 10 +++++-- .../Controller/internal/hostbridge/manager.go | 2 +- docs/STRIP-TIMING-TRACE-CHECKPOINT.md | 11 +++++++ 7 files changed, 89 insertions(+), 17 deletions(-) create mode 100644 docs/STRIP-TIMING-TRACE-CHECKPOINT.md diff --git a/Tools/Controller/controller.go b/Tools/Controller/controller.go index 1eed2bf2..bc2eb3a4 100644 --- a/Tools/Controller/controller.go +++ b/Tools/Controller/controller.go @@ -2337,12 +2337,26 @@ func (client *Client) MapLearnedRF( // Snapshot returns cached board state without polling firmware. When no // transport candidate is cached, serial-reset capability discovery may perform // one throttled host-side device enumeration. -func (client *Client) Snapshot() Snapshot { - snapshot := client.runtime.Snapshot() - resetLinesAvailable, resetLinesPort, resetLinesReason := client.runtime.ResetLinesCapability() +func (client *Client) Snapshot() Snapshot { return client.snapshot(true) } + +// BoardSnapshot is for internal live-state observers. It omits catalogs, macro +// libraries and reset-device enumeration; public query snapshots remain complete. +func (client *Client) BoardSnapshot() Snapshot { return client.snapshot(false) } + +func (client *Client) snapshot(includeCatalog bool) Snapshot { + var snapshot control.Snapshot + var resetLinesAvailable bool + var resetLinesPort ports.Info + var resetLinesReason string + if includeCatalog { + snapshot = client.runtime.Snapshot() + resetLinesAvailable, resetLinesPort, resetLinesReason = client.runtime.ResetLinesCapability() + } else { + snapshot = client.runtime.BoardSnapshot() + } // Library snapshots belong to client queries, not the hot board-status // path: copying a long take for every internal status check is unnecessary. - if runner := client.runtime.MacroRunner(); runner != nil { + if runner := client.runtime.MacroRunner(); includeCatalog && runner != nil { snapshot.Macros = runner.Snapshot() } client.illuminationMu.RLock() @@ -2351,7 +2365,10 @@ func (client *Client) Snapshot() Snapshot { if !snapshot.Connected || !snapshot.HaveSettings || !snapshot.HaveStatus { illumination = IlluminationState{} } - stripEffects := control.SupportedConfiguredStripEffectDescriptors(snapshot.Connected, snapshot.Hello.Capabilities, client.currentStripEffects()) + var stripEffects []control.StripEffectDescriptor + if includeCatalog { + stripEffects = control.SupportedConfiguredStripEffectDescriptors(snapshot.Connected, snapshot.Hello.Capabilities, client.currentStripEffects()) + } return Snapshot{ Connected: snapshot.Connected, Paused: snapshot.Paused, diff --git a/Tools/Controller/controller_test.go b/Tools/Controller/controller_test.go index 00754016..a24b8474 100644 --- a/Tools/Controller/controller_test.go +++ b/Tools/Controller/controller_test.go @@ -27,12 +27,18 @@ func TestPublicPortInfoPublishesCrossPlatformDisplayIdentity(t *testing.T) { func TestSharedFacadeSnapshotUsesRuntimeEffectCatalog(t *testing.T) { runtime := control.New(control.Options{}) config := appconfig.Defaults() + configReads := 0 engine := control.NewCommandEngine(runtime, control.CommandOptions{ - HostConfig: func() appconfig.Config { return config }, + HostConfig: func() appconfig.Config { configReads++; return config }, }) client := AttachSharedRuntime(runtime, engine) defer client.Shutdown() + configReads = 0 + board := client.BoardSnapshot() + if configReads != 0 || len(board.Effects) != 0 || len(board.StripEffects) != 0 { + t.Fatal("internal board observation expanded configuration or catalogs") + } snapshot := client.Snapshot() if len(snapshot.Effects) != len(config.StripEffects) { t.Fatalf("snapshot effects=%d, want runtime catalog=%d", len(snapshot.Effects), len(config.StripEffects)) diff --git a/Tools/Controller/internal/control/media_timeline_test.go b/Tools/Controller/internal/control/media_timeline_test.go index 4f003ac1..7c7cee14 100644 --- a/Tools/Controller/internal/control/media_timeline_test.go +++ b/Tools/Controller/internal/control/media_timeline_test.go @@ -583,3 +583,33 @@ func TestMediaTimelinePauseResumeRestoresLatchedValueAndSeekRequiresArming(t *te } waitTimeline(t, runtime, func(s MediaTimelineStatus) bool { return s.ArmedEpoch == 2 && s.RebasedSteps == 2 }) } + +func TestBoardSnapshotSkipsCatalogProvidersAndRetainsLiveState(t *testing.T) { + runtime := New(Options{}) + defer runtime.Close() + calls := 0 + runtime.setMacroRunner(NewMacroRunner(nil, func() []appconfig.Macro { + calls++ + return []appconfig.Macro{{ID: 7, Name: "fixture", Mode: macroModeHost, + Steps: []appconfig.MacroStep{{Kind: "relay", Target: 7, Value: 0}}}} + }, nil)) + runtime.mu.Lock() + runtime.haveStatus = true + runtime.status.DoorOpen = true + runtime.statusUpdated = time.Now() + runtime.mu.Unlock() + light := runtime.BoardSnapshot() + if calls != 0 { + t.Fatalf("board observation expanded catalog %d times", calls) + } + full := runtime.Snapshot() + if calls == 0 || len(full.Effects) == 0 { + t.Fatal("public snapshot lost its catalog") + } + if light.Status != full.Status || light.StatusUpdated != full.StatusUpdated || !light.HaveStatus { + t.Fatal("board observation lost authoritative state or timestamp") + } + if len(light.Effects) != 0 || len(light.EffectGroups) != 0 { + t.Fatal("board observation includes catalog") + } +} diff --git a/Tools/Controller/internal/control/output_streams.go b/Tools/Controller/internal/control/output_streams.go index 4374c469..a0361258 100644 --- a/Tools/Controller/internal/control/output_streams.go +++ b/Tools/Controller/internal/control/output_streams.go @@ -216,7 +216,14 @@ func retainedStatusOwner(owner *retainedOutput) string { } func (scheduler *OutputScheduler) targetSnapshot() Snapshot { - reporter, ok := scheduler.target.(outputCapabilityReporter) + return outputBoardSnapshot(scheduler.target) +} + +func outputBoardSnapshot(target outputCommander) Snapshot { + if reporter, ok := target.(interface{ BoardSnapshot() Snapshot }); ok { + return reporter.BoardSnapshot() + } + reporter, ok := target.(outputCapabilityReporter) if !ok { return Snapshot{} } @@ -823,16 +830,11 @@ func (scheduler *OutputScheduler) sendStatusDescriptor( } func supportsNativeStatusEffects(target outputCommander) bool { - reporter, ok := target.(outputCapabilityReporter) - return ok && reporter.Snapshot().Hello.Capabilities&native.CapabilityStatusEffects != 0 + return outputBoardSnapshot(target).Hello.Capabilities&native.CapabilityStatusEffects != 0 } func supportsNativeStatusProfiles(target outputCommander) bool { - reporter, ok := target.(outputCapabilityReporter) - if !ok { - return false - } - capabilities := reporter.Snapshot().Hello.Capabilities + capabilities := outputBoardSnapshot(target).Hello.Capabilities return capabilities&native.CapabilityStatusEffects != 0 && capabilities&native.CapabilityStatusProfiles != 0 } diff --git a/Tools/Controller/internal/control/runtime.go b/Tools/Controller/internal/control/runtime.go index 723448a7..312a71d3 100644 --- a/Tools/Controller/internal/control/runtime.go +++ b/Tools/Controller/internal/control/runtime.go @@ -815,7 +815,13 @@ func eventKindMatches(requested, actual string) bool { (requested != "" && strings.HasPrefix(actual, requested+".")) } -func (runtime *Runtime) Snapshot() Snapshot { +func (runtime *Runtime) Snapshot() Snapshot { return runtime.snapshot(true) } + +// BoardSnapshot returns cached live state without expanding effect catalogs. +// Internal observers must not copy the user library for every telemetry event. +func (runtime *Runtime) BoardSnapshot() Snapshot { return runtime.snapshot(false) } + +func (runtime *Runtime) snapshot(includeCatalog bool) Snapshot { programState := runtime.ProgramState() rfLearning := runtime.RFLearnState() emergencyStop := runtime.EmergencyStop() @@ -870,7 +876,7 @@ func (runtime *Runtime) Snapshot() Snapshot { // user effects. Never hold the connection lock across that presentation work: // a waiting writer also blocks subsequent RWMutex readers, including the // prepared executor's small session-generation check. - if runner != nil { + if includeCatalog && runner != nil { snapshot.Effects = runner.EffectCatalog() snapshot.EffectGroups = runner.EffectGroups() } diff --git a/Tools/Controller/internal/hostbridge/manager.go b/Tools/Controller/internal/hostbridge/manager.go index ed93e094..430f9be8 100644 --- a/Tools/Controller/internal/hostbridge/manager.go +++ b/Tools/Controller/internal/hostbridge/manager.go @@ -977,7 +977,7 @@ func (manager *Manager) eventLoop(afterID uint64) { ) } config := manager.store.Current() - manager.observeEventPresentation(config, event, manager.client.Snapshot) + manager.observeEventPresentation(config, event, manager.client.BoardSnapshot) manager.dispatchWebhooks(config, event) manager.dispatchTextMappings(config, event) manager.dispatchNotification(config, event) diff --git a/docs/STRIP-TIMING-TRACE-CHECKPOINT.md b/docs/STRIP-TIMING-TRACE-CHECKPOINT.md new file mode 100644 index 00000000..51231627 --- /dev/null +++ b/docs/STRIP-TIMING-TRACE-CHECKPOINT.md @@ -0,0 +1,11 @@ +# Strip integration timing checkpoint + +The synchronized restore failure on host ci.1653 was distinct from serial framing corruption. A five-second execution trace showed the timeline worker starting its request at +173.45 ms, the serial reader delivering the ACK at +189.335 ms, and the worker resuming at +234.281 ms. Delivery took about 15.9 ms; the runnable worker then waited 44.95 ms. A GC sweep-termination stop-the-world interval occupied 15.59 ms of that wait. These are host observations, not hardware arrival timestamps. + +The allocation triggering that collection came from hostbridge event presentation -> Client.Snapshot -> effect groups -> Store.Current -> deep configuration clone. Door, segment and status-light observers do not need the effect library. BoardSnapshot now supplies their cached live state without catalog providers, macro library copies or reset-device enumeration. The facade regression also caught an indirect catalog expansion through output-status capability checks; those checks now prefer the same board-only observation while preserving the existing fallback for other commanders. Full public query snapshots still contain the catalogs. Regression coverage checks provider exclusion, retained state/timestamps, public catalog preservation and shared fresh presentation observations. + +This removes demonstrated unnecessary allocation; it does not prove every prior timing failure had the same cause or guarantee a Windows scheduling deadline. The 50 ms limit remains unchanged. The next gate is hosted CI, exact-artifact host update, and a coordinated unprofiled synchronized replay with cleanup and counter checks. Do not count the profiled failure as playback acceptance. + +Standalone evidence remains separate: a three-pixel 180-second stream and 150 seat Up requests over 15 seconds had no new framing/CRC/reset errors. Identical seat commands coalesce, so this does not establish 150 relay cycles. Police, White Thunder, Converging, Fade and Pulse demos are available; visual, physical and audible acceptance remains with the user. Android installation and native modal visual verification are still open. + +Private traces, machine configuration and captures are retained locally, not published. From 16cd25a1cc7945fbc775243d23b317c445f5398d Mon Sep 17 00:00:00 2001 From: DRSDavidSoft <4673812+DRSDavidSoft@users.noreply.github.com> Date: Tue, 27 Oct 2026 03:50:19 +0330 Subject: [PATCH 13/19] Yield delivered serial replies before blocking reads and reduce event allocations --- Tools/Controller/internal/control/history.go | 24 ++++++++--------- .../internal/control/history_test.go | 26 +++++++++++++++++++ Tools/Controller/internal/control/macros.go | 8 ++++-- .../Controller/internal/hostbridge/manager.go | 2 +- Tools/Controller/internal/link/session.go | 14 +++++++++- docs/STRIP-TIMING-TRACE-CHECKPOINT.md | 8 ++++++ 6 files changed, 66 insertions(+), 16 deletions(-) diff --git a/Tools/Controller/internal/control/history.go b/Tools/Controller/internal/control/history.go index f927080a..9cbaa666 100644 --- a/Tools/Controller/internal/control/history.go +++ b/Tools/Controller/internal/control/history.go @@ -320,10 +320,7 @@ func (runtime *Runtime) pruneHistoryLocked(now time.Time) { statusStart++ } if statusStart != 0 { - runtime.statusHistory = append( - []StatusSample(nil), - runtime.statusHistory[statusStart:]..., - ) + runtime.statusHistory = retainHistorySuffix(runtime.statusHistory, statusStart) } timelineStart := 0 for timelineStart < len(runtime.timeline) && @@ -331,20 +328,23 @@ func (runtime *Runtime) pruneHistoryLocked(now time.Time) { timelineStart++ } if timelineStart != 0 { - runtime.timeline = append( - []TimelineEntry(nil), - runtime.timeline[timelineStart:]..., - ) + runtime.timeline = retainHistorySuffix(runtime.timeline, timelineStart) } } if runtime.timelineLimit > 0 && len(runtime.timeline) > runtime.timelineLimit { - runtime.timeline = append( - []TimelineEntry(nil), - runtime.timeline[len(runtime.timeline)-runtime.timelineLimit:]..., - ) + runtime.timeline = retainHistorySuffix(runtime.timeline, len(runtime.timeline)-runtime.timelineLimit) } } +// Call only under historyMu. Public readers already receive detached copies. +// Reuse bounded storage and clear discarded references instead of allocating +// the entire retained history for each new event at the retention limit. +func retainHistorySuffix[T any](entries []T, start int) []T { + retained := copy(entries, entries[start:]) + clear(entries[retained:]) + return entries[:retained] +} + func (runtime *Runtime) deduplicateStatusHistoryLocked() { if len(runtime.statusHistory) < 2 { return diff --git a/Tools/Controller/internal/control/history_test.go b/Tools/Controller/internal/control/history_test.go index 6981fd63..9826e951 100644 --- a/Tools/Controller/internal/control/history_test.go +++ b/Tools/Controller/internal/control/history_test.go @@ -212,3 +212,29 @@ func equalStatusSamples(left, right []StatusSample) bool { } return true } + +func TestHistoryPruningReusesStorageAndClearsDiscardedReferences(t *testing.T) { + now := time.Now() + backing := make([]TimelineEntry, 4) + runtime := &Runtime{historyRetention: time.Hour, timelineLimit: 2} + allocations := testing.AllocsPerRun(100, func() { + backing[0] = TimelineEntry{Time: now.Add(-2 * time.Hour), Kind: "expired"} + backing[1] = TimelineEntry{Time: now, Kind: "oldest"} + backing[2] = TimelineEntry{Time: now, Kind: "kept-first"} + backing[3] = TimelineEntry{Time: now, Kind: "kept-last"} + runtime.timeline = backing + runtime.pruneHistoryLocked(now) + }) + if allocations != 0 { + t.Fatalf("prune allocated %.1f times per event", allocations) + } + if len(runtime.timeline) != 2 || runtime.timeline[0].Kind != "kept-first" || runtime.timeline[1].Kind != "kept-last" { + t.Fatalf("wrong retained ordering: %+v", runtime.timeline) + } + if backing[2].Kind != "" || backing[3].Kind != "" { + t.Fatal("discarded references retained") + } + if &runtime.timeline[0] != &backing[0] { + t.Fatal("prune replaced backing storage") + } +} diff --git a/Tools/Controller/internal/control/macros.go b/Tools/Controller/internal/control/macros.go index ef603059..7da37291 100644 --- a/Tools/Controller/internal/control/macros.go +++ b/Tools/Controller/internal/control/macros.go @@ -248,17 +248,21 @@ func (runner *MacroRunner) Snapshot() MacroSnapshot { // interface. Recorded sequences and rendered strip streams retain their // distinct engines, but share discovery, stable references, and commands. func (runner *MacroRunner) EffectCatalog() []EffectDescriptor { - config := appconfig.Defaults() + var config appconfig.Config if runner.hostConfig != nil { config = runner.hostConfig() + } else { + config = appconfig.Defaults() } return EffectCatalogWithGroups(runner.List(), config.StripEffects, config.EffectGroups) } func (runner *MacroRunner) EffectGroups() []EffectGroupDescriptor { - config := appconfig.Defaults() + var config appconfig.Config if runner.hostConfig != nil { config = runner.hostConfig() + } else { + config = appconfig.Defaults() } config.Macros = runner.List() return EffectGroupCatalog(config) diff --git a/Tools/Controller/internal/hostbridge/manager.go b/Tools/Controller/internal/hostbridge/manager.go index 430f9be8..37e43ed5 100644 --- a/Tools/Controller/internal/hostbridge/manager.go +++ b/Tools/Controller/internal/hostbridge/manager.go @@ -1125,7 +1125,7 @@ func (manager *Manager) dispatchNotification( } return } - job, ok, err := notificationJobFromObservation(config, event, manager.client.Snapshot) + job, ok, err := notificationJobFromObservation(config, event, manager.client.BoardSnapshot) if err != nil { manager.recordError("notification actions: " + err.Error()) return diff --git a/Tools/Controller/internal/link/session.go b/Tools/Controller/internal/link/session.go index f3427dd2..d9c8e6e3 100644 --- a/Tools/Controller/internal/link/session.go +++ b/Tools/Controller/internal/link/session.go @@ -7,6 +7,7 @@ import ( "io" "net" "net/url" + "runtime" "strings" "sync" "sync/atomic" @@ -842,6 +843,7 @@ func (s *Session) readLoop() { for { n, err := s.port.Read(buffer) + replyDelivered := false receivedAt := time.Now() s.lastReadReturned.Store(receivedAt.UnixNano()) if n > 0 { @@ -853,7 +855,9 @@ func (s *Session) readLoop() { for _, frame := range frames { s.observeRFClock(frame) s.observeHello(frame) - if !s.deliver(frame) { + if s.deliver(frame) { + replyDelivered = true + } else { s.publish(Event{Frame: frame, ReceivedAt: receivedAt}) } } @@ -881,6 +885,14 @@ func (s *Session) readLoop() { return default: } + if replyDelivered && runtime.GOOS == "windows" { + // Let the awakened request consume its response before this reader + // enters another blocking Win32 serial syscall. Execution traces + // showed the waiter remaining runnable for tens of milliseconds + // while the reader blocked, despite another idle P. + // Yield once per decoded batch, without sleeps or changed deadlines. + runtime.Gosched() + } } } diff --git a/docs/STRIP-TIMING-TRACE-CHECKPOINT.md b/docs/STRIP-TIMING-TRACE-CHECKPOINT.md index 51231627..200909ae 100644 --- a/docs/STRIP-TIMING-TRACE-CHECKPOINT.md +++ b/docs/STRIP-TIMING-TRACE-CHECKPOINT.md @@ -9,3 +9,11 @@ This removes demonstrated unnecessary allocation; it does not prove every prior Standalone evidence remains separate: a three-pixel 180-second stream and 150 seat Up requests over 15 seconds had no new framing/CRC/reset errors. Identical seat commands coalesce, so this does not establish 150 relay cycles. Police, White Thunder, Converging, Fade and Pulse demos are available; visual, physical and audible acceptance remains with the user. Android installation and native modal visual verification are still open. Private traces, machine configuration and captures are retained locally, not published. + +## Post-update result + +Host ci.1654 passed hosted builds and was installed with independently verified live identity. Its unprofiled short replay still failed restore at 63.5 ms before observed playback. One post-fix trace failed at 79.2 ms: the worker waited runnable about 23.2 ms after a write syscall and 43.7 ms after ACK delivery. The relevant stop-the-world intervals were only about 0.50 ms and 0.07 ms, so those pauses alone do not explain the delays. + +The new trace exposed further avoidable allocation: retained timeline trimming allocated a new whole-history slice, and door-warning notification context still expanded the full catalog. The follow-up reuses history storage under its existing lock and clears discarded references; public readers remain detached. Notification context now uses BoardSnapshot. Catalog construction also avoids building defaults only to overwrite them with configured values. Regression coverage checks zero allocations during pruning, retained ordering and cleared references, plus existing history and notification behavior. These reductions remain performance fixes, not proof of synchronized acceptance. No timer, priority or GC-disable workaround is applied. + +The trace also shows ACK delivery at +225.376 ms followed immediately by the reader entering another Win32 blocking wait at +225.394 ms, while a different Go processor remained idle until +253.318 ms. The waiter ran at +269.071 ms. This supports a local run-queue handoff problem rather than claiming GC explains the whole delay. The Windows reader now cooperatively yields once after a decoded batch delivers a pending response, before its next blocking read. It does not sleep, modify process priorities, disable GC or change deadlines. Live comparison remains required. From 4dbdd1798cbb78e6e745996cf2afa6f0dad51337 Mon Sep 17 00:00:00 2001 From: DRSDavidSoft <4673812+DRSDavidSoft@users.noreply.github.com> Date: Tue, 27 Oct 2026 04:09:20 +0330 Subject: [PATCH 14/19] Reuse bounded event log storage on timeline publication --- .../internal/control/event_log_reuse_test.go | 32 +++++++++++++++++++ Tools/Controller/internal/control/history.go | 10 +++--- Tools/Controller/internal/control/runtime.go | 6 ++-- docs/STRIP-TIMING-TRACE-CHECKPOINT.md | 6 ++++ 4 files changed, 46 insertions(+), 8 deletions(-) create mode 100644 Tools/Controller/internal/control/event_log_reuse_test.go diff --git a/Tools/Controller/internal/control/event_log_reuse_test.go b/Tools/Controller/internal/control/event_log_reuse_test.go new file mode 100644 index 00000000..6de6ffb3 --- /dev/null +++ b/Tools/Controller/internal/control/event_log_reuse_test.go @@ -0,0 +1,32 @@ +package control + +import "testing" + +func TestEventLogsRetainOrderWithoutReplacingBoundedStorage(t *testing.T) { + runtime := New(Options{}) + defer runtime.Close() + runtime.eventLog = make([]Event, 512, 513) + runtime.activityLog = make([]Event, 512, 513) + runtime.rfActivityLog = make([]Event, 20, 21) + for i := range runtime.eventLog { + runtime.eventLog[i].ID = uint64(i + 1) + runtime.activityLog[i].ID = uint64(i + 1) + } + for i := range runtime.rfActivityLog { + runtime.rfActivityLog[i].ID = uint64(i + 1) + } + eventBase, activityBase, rfBase := &runtime.eventLog[0], &runtime.activityLog[0], &runtime.rfActivityLog[0] + runtime.nextEventID = 512 + event := runtime.publishEvent(Event{Kind: "rf.fixture", Stream: EventStreamActivity}) + if &runtime.eventLog[0] != eventBase || &runtime.activityLog[0] != activityBase || &runtime.rfActivityLog[0] != rfBase { + t.Fatal("bounded event publication replaced backing storage") + } + for _, entries := range [][]Event{runtime.eventLog, runtime.activityLog, runtime.rfActivityLog} { + if entries[0].ID != 2 || entries[len(entries)-1].ID != event.ID { + t.Fatal("trim changed event order") + } + if entries[:cap(entries)][len(entries)].ID != 0 { + t.Fatal("trim retained discarded tail references") + } + } +} diff --git a/Tools/Controller/internal/control/history.go b/Tools/Controller/internal/control/history.go index 9cbaa666..23839d87 100644 --- a/Tools/Controller/internal/control/history.go +++ b/Tools/Controller/internal/control/history.go @@ -320,7 +320,7 @@ func (runtime *Runtime) pruneHistoryLocked(now time.Time) { statusStart++ } if statusStart != 0 { - runtime.statusHistory = retainHistorySuffix(runtime.statusHistory, statusStart) + runtime.statusHistory = retainSliceSuffix(runtime.statusHistory, statusStart) } timelineStart := 0 for timelineStart < len(runtime.timeline) && @@ -328,18 +328,18 @@ func (runtime *Runtime) pruneHistoryLocked(now time.Time) { timelineStart++ } if timelineStart != 0 { - runtime.timeline = retainHistorySuffix(runtime.timeline, timelineStart) + runtime.timeline = retainSliceSuffix(runtime.timeline, timelineStart) } } if runtime.timelineLimit > 0 && len(runtime.timeline) > runtime.timelineLimit { - runtime.timeline = retainHistorySuffix(runtime.timeline, len(runtime.timeline)-runtime.timelineLimit) + runtime.timeline = retainSliceSuffix(runtime.timeline, len(runtime.timeline)-runtime.timelineLimit) } } -// Call only under historyMu. Public readers already receive detached copies. +// Call only with exclusive ownership of the slice. Public readers receive detached copies. // Reuse bounded storage and clear discarded references instead of allocating // the entire retained history for each new event at the retention limit. -func retainHistorySuffix[T any](entries []T, start int) []T { +func retainSliceSuffix[T any](entries []T, start int) []T { retained := copy(entries, entries[start:]) clear(entries[retained:]) return entries[:retained] diff --git a/Tools/Controller/internal/control/runtime.go b/Tools/Controller/internal/control/runtime.go index 312a71d3..cd3434f4 100644 --- a/Tools/Controller/internal/control/runtime.go +++ b/Tools/Controller/internal/control/runtime.go @@ -4291,12 +4291,12 @@ func (runtime *Runtime) publishEvent(event Event) Event { runtime.recordRFReceptionLocked(&event) runtime.eventLog = append(runtime.eventLog, event) if len(runtime.eventLog) > 512 { - runtime.eventLog = append([]Event(nil), runtime.eventLog[len(runtime.eventLog)-512:]...) + runtime.eventLog = retainSliceSuffix(runtime.eventLog, len(runtime.eventLog)-512) } if event.Stream == EventStreamActivity { runtime.activityLog = append(runtime.activityLog, event) if len(runtime.activityLog) > 512 { - runtime.activityLog = append([]Event(nil), runtime.activityLog[len(runtime.activityLog)-512:]...) + runtime.activityLog = retainSliceSuffix(runtime.activityLog, len(runtime.activityLog)-512) } } // Keep the RF manager's bounded history independently of high-rate media @@ -4304,7 +4304,7 @@ func (runtime *Runtime) publishEvent(event Event) Event { if strings.HasPrefix(event.Kind, "rf.") && event.Source != "bridge" { runtime.rfActivityLog = append(runtime.rfActivityLog, event) if len(runtime.rfActivityLog) > 20 { - runtime.rfActivityLog = append([]Event(nil), runtime.rfActivityLog[len(runtime.rfActivityLog)-20:]...) + runtime.rfActivityLog = retainSliceSuffix(runtime.rfActivityLog, len(runtime.rfActivityLog)-20) } } close(runtime.eventNotify) diff --git a/docs/STRIP-TIMING-TRACE-CHECKPOINT.md b/docs/STRIP-TIMING-TRACE-CHECKPOINT.md index 200909ae..a2a3c61a 100644 --- a/docs/STRIP-TIMING-TRACE-CHECKPOINT.md +++ b/docs/STRIP-TIMING-TRACE-CHECKPOINT.md @@ -17,3 +17,9 @@ Host ci.1654 passed hosted builds and was installed with independently verified The new trace exposed further avoidable allocation: retained timeline trimming allocated a new whole-history slice, and door-warning notification context still expanded the full catalog. The follow-up reuses history storage under its existing lock and clears discarded references; public readers remain detached. Notification context now uses BoardSnapshot. Catalog construction also avoids building defaults only to overwrite them with configured values. Regression coverage checks zero allocations during pruning, retained ordering and cleared references, plus existing history and notification behavior. These reductions remain performance fixes, not proof of synchronized acceptance. No timer, priority or GC-disable workaround is applied. The trace also shows ACK delivery at +225.376 ms followed immediately by the reader entering another Win32 blocking wait at +225.394 ms, while a different Go processor remained idle until +253.318 ms. The waiter ran at +269.071 ms. This supports a local run-queue handoff problem rather than claiming GC explains the whole delay. The Windows reader now cooperatively yields once after a decoded batch delivers a pending response, before its next blocking read. It does not sleep, modify process priorities, disable GC or change deadlines. Live comparison remains required. + +## Reply-handoff candidate result + +ci.1655 was built, installed and live-identity verified. An unprofiled restore still timed out at 99.8 ms; its unmatched ACK reached the read loop after the fault, with zero observer delay. Nearby board timestamps suggest generation preceded host receipt by roughly 97 ms; this is a clock-alignment inference, not a hardware capture. A subsequent traced short replay did restore and enter Playing. Its configure and frame-stage acknowledgements resumed the waiter within microseconds after delivery, confirming the new handoff path operates. However the stage took 38.2 ms end-to-end, and publishing evidence before show consumed the remaining deadline; show was refused at 50.9 ms late. + +That trace exposed roughly 400 KB event-log allocation at publication: the independent 512-event bounded log still reallocated its retained slice after every append. The follow-up reuses and clears bounded event, activity and RF-log storage under eventMu, preserving order and retention. Regression coverage checks backing storage reuse, newest/oldest order and cleared discarded references. This does not resolve the separate variable read-delivery delay by itself; synchronized acceptance remains open. Native exact-zero seek settlement is tracked separately in Pealayer issue 219. From 2857bf2d1afc4489fd3e1657dacf301d22a03c7a Mon Sep 17 00:00:00 2001 From: DRSDavidSoft <4673812+DRSDavidSoft@users.noreply.github.com> Date: Tue, 27 Oct 2026 04:24:49 +0330 Subject: [PATCH 15/19] Bound Windows idle serial reads to ten milliseconds --- .../internal/link/response_timing_test.go | 31 +++++++++++++++++++ Tools/Controller/internal/link/session.go | 8 ++++- docs/STRIP-TIMING-TRACE-CHECKPOINT.md | 6 ++++ 3 files changed, 44 insertions(+), 1 deletion(-) diff --git a/Tools/Controller/internal/link/response_timing_test.go b/Tools/Controller/internal/link/response_timing_test.go index c1191cef..44592186 100644 --- a/Tools/Controller/internal/link/response_timing_test.go +++ b/Tools/Controller/internal/link/response_timing_test.go @@ -49,3 +49,34 @@ func TestLateACKRetainsReceiveTimeAfterRequestCancellation(t *testing.T) { t.Fatal("late ACK was swallowed by the canceled waiter") } } + +func TestFragmentedACKSurvivesEmptyReadIntervals(t *testing.T) { + port := newFakePort() + session := NewForPort("TEST", port) + defer session.Close() + port.onWrite = func(encoded []byte) { + request, err := native.Decode(encoded) + if err != nil { + t.Fatal(err) + } + reply, err := native.Encode(native.Frame{Opcode: native.OpACK, Seq: request.Seq, Payload: []byte{request.Opcode, 0}}) + if err != nil { + t.Fatal(err) + } + for _, value := range reply { + port.reads <- []byte{value} + port.reads <- nil + } + } + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + frame, err := session.Request(ctx, native.OpRelayAllOff, nil, native.OpACK) + if err != nil || frame.Opcode != native.OpACK { + t.Fatalf("partial ACK lost across idle reads: %+v, %v", frame, err) + } + select { + case event := <-session.Events(): + t.Fatalf("partial/idle read produced an error or unsolicited frame: %+v", event) + default: + } +} diff --git a/Tools/Controller/internal/link/session.go b/Tools/Controller/internal/link/session.go index d9c8e6e3..de8a93ab 100644 --- a/Tools/Controller/internal/link/session.go +++ b/Tools/Controller/internal/link/session.go @@ -303,7 +303,13 @@ func OpenContext(ctx context.Context, name string, baudRate int) (*Session, erro if err := ctx.Err(); err != nil { return newSession(name, port), fmt.Errorf("open %s canceled after serial acquisition: %w", name, err) } - if err := port.SetReadTimeout(DefaultReadTimeout); err != nil { + readTimeout := DefaultReadTimeout + if runtime.GOOS == "windows" { + // Bound idle overlapped-read residency on Windows/CH340. Empty reads + // retain decoder state; this is not a response or cue deadline. + readTimeout = 10 * time.Millisecond + } + if err := port.SetReadTimeout(readTimeout); err != nil { configureErr := fmt.Errorf("configure %s: %w", name, err) if closeErr := port.Close(); closeErr != nil { // Return a close-only Session together with the error so the caller diff --git a/docs/STRIP-TIMING-TRACE-CHECKPOINT.md b/docs/STRIP-TIMING-TRACE-CHECKPOINT.md index a2a3c61a..8931781e 100644 --- a/docs/STRIP-TIMING-TRACE-CHECKPOINT.md +++ b/docs/STRIP-TIMING-TRACE-CHECKPOINT.md @@ -23,3 +23,9 @@ The trace also shows ACK delivery at +225.376 ms followed immediately by the rea ci.1655 was built, installed and live-identity verified. An unprofiled restore still timed out at 99.8 ms; its unmatched ACK reached the read loop after the fault, with zero observer delay. Nearby board timestamps suggest generation preceded host receipt by roughly 97 ms; this is a clock-alignment inference, not a hardware capture. A subsequent traced short replay did restore and enter Playing. Its configure and frame-stage acknowledgements resumed the waiter within microseconds after delivery, confirming the new handoff path operates. However the stage took 38.2 ms end-to-end, and publishing evidence before show consumed the remaining deadline; show was refused at 50.9 ms late. That trace exposed roughly 400 KB event-log allocation at publication: the independent 512-event bounded log still reallocated its retained slice after every append. The follow-up reuses and clears bounded event, activity and RF-log storage under eventMu, preserving order and retention. Regression coverage checks backing storage reuse, newest/oldest order and cleared discarded references. This does not resolve the separate variable read-delivery delay by itself; synchronized acceptance remains open. Native exact-zero seek settlement is tracked separately in Pealayer issue 219. + +## Shorter Windows read interval experiment + +ci.1656 was verified and installed. Its short unprofiled replay restored and entered Playing, but show expired during a 29.4 ms write after earlier frame operations consumed the shared cue budget. Cleanup retained Stop/Stop, relays off, framing/CRC zero and the same reset count. No full run was claimed. + +The next diagnostic changes only the Windows idle serial read interval from 100 ms to 10 ms, keeping the persistent frame decoder and the original response/cue deadlines. This tests whether shorter blocking-read residency reduces driver/scheduler latency; it is a hypothesis, not a confirmed driver diagnosis. A fragmented ACK regression inserts empty reads between bytes to ensure timeout polling cannot discard frame state. No priority, GC or physical-output policy changes accompany it. From 6233453c3fe4b04e2e619166f84b9d33768c57f5 Mon Sep 17 00:00:00 2001 From: DRSDavidSoft <4673812+DRSDavidSoft@users.noreply.github.com> Date: Tue, 27 Oct 2026 04:47:37 +0330 Subject: [PATCH 16/19] Record kernel scheduling evidence for synchronized playback --- docs/STRIP-TIMING-TRACE-CHECKPOINT.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/docs/STRIP-TIMING-TRACE-CHECKPOINT.md b/docs/STRIP-TIMING-TRACE-CHECKPOINT.md index 8931781e..b743f1ed 100644 --- a/docs/STRIP-TIMING-TRACE-CHECKPOINT.md +++ b/docs/STRIP-TIMING-TRACE-CHECKPOINT.md @@ -29,3 +29,15 @@ That trace exposed roughly 400 KB event-log allocation at publication: the indep ci.1656 was verified and installed. Its short unprofiled replay restored and entered Playing, but show expired during a 29.4 ms write after earlier frame operations consumed the shared cue budget. Cleanup retained Stop/Stop, relays off, framing/CRC zero and the same reset count. No full run was claimed. The next diagnostic changes only the Windows idle serial read interval from 100 ms to 10 ms, keeping the persistent frame decoder and the original response/cue deadlines. This tests whether shorter blocking-read residency reduces driver/scheduler latency; it is a hypothesis, not a confirmed driver diagnosis. A fragmented ACK regression inserts empty reads between bytes to ensure timeout polling cannot discard frame state. No priority, GC or physical-output policy changes accompany it. + +## Kernel scheduling evidence + +ci.1657 passed all five hosted build targets and repository health, then was installed and independently live-identity verified. The settled paused comparison did not show a clear improvement: 40 fresh status calls at 100 ms read timeout had median/p95/max 15.60/30.60/41.90 ms; at 10 ms they measured 17.63/34.62/39.57 ms. Both had no errors or calls over 50 ms. Five-second idle CPU samples were about 0.025 and 0.037 CPU cores respectively; these short samples are descriptive, not a statistical performance claim. A separate reconnect-overlapping sample was excluded and retained privately. + +The unprofiled short replay entered Playing, but the first strip configure/stage/show reached cumulative ACK lateness 5.49/35.62/55.62 ms. The unchanged deadline interrupted playback. A subsequent paired Go/Windows scheduling trace failed an earlier restore: 54.3 ms total, including 39.3 ms in the write call. Both ended paused with seats stopped, relays off, and unchanged zero framing/CRC counters and reset count. + +The kernel trace explains why a long Go write duration is not necessarily a slow serial write. Relative to the Go trace synchronization point, the writer entered its overlapped wait at +208.406 ms. Windows readied that thread at +208.481 ms and scheduled it at +208.970 ms. Go then queued the goroutine as runnable, but it resumed at +247.223 ms. Separately, the serial reader's OS thread became ready at +212.004 ms and was not scheduled until +261.813 ms: approximately 49.81 ms of kernel ready delay. Its goroutine subsequently resumed at +277.79 ms. These observations establish scheduler contention in this captured failure; they do not establish physical byte arrival time or explain every prior failure. + +Across the four CPUs during that reader-ready interval, about 71.2 CPU-ms went to Pealayer, 53.1 to Remote Desktop Services, 21.3 to antimalware, and 20.5 to the controller. Only about 0.06 CPU-ms was idle. Higher dynamic priorities dominated much of the interval. No services, security controls, affinity, timer resolution or scheduling policy were disabled or persistently changed. The named 16 MB circular Windows capture stopped automatically after 50 seconds, merged successfully, and reported no lost events. The retained interval covers the failing request. ETL, decoded records and process-specific details remain private. + +This evidence does not support calling the shorter read timeout a driver fix. Next compare one bounded playback run with temporary host priority and automatic restoration; any production scheduling change needs its own tests and normal-load acceptance. Synchronized playback, exact-zero native seek, native modal visual checks, device installation and human output/audio acceptance remain open. From 86ada2a905067503a368eef98a51e47a8e50fdb7 Mon Sep 17 00:00:00 2001 From: DRSDavidSoft <4673812+DRSDavidSoft@users.noreply.github.com> Date: Tue, 27 Oct 2026 04:52:59 +0330 Subject: [PATCH 17/19] Scope Windows playback priority to prepared serial ownership --- Tools/Controller/internal/link/session.go | 41 ++++++- .../link/timeline_reservation_test.go | 56 +++++++++ .../internal/playbackpriority/lease.go | 74 +++++++++++ .../internal/playbackpriority/lease_test.go | 116 ++++++++++++++++++ .../playbackpriority/priority_other.go | 5 + .../playbackpriority/priority_windows.go | 14 +++ docs/STRIP-TIMING-TRACE-CHECKPOINT.md | 8 ++ 7 files changed, 313 insertions(+), 1 deletion(-) create mode 100644 Tools/Controller/internal/playbackpriority/lease.go create mode 100644 Tools/Controller/internal/playbackpriority/lease_test.go create mode 100644 Tools/Controller/internal/playbackpriority/priority_other.go create mode 100644 Tools/Controller/internal/playbackpriority/priority_windows.go diff --git a/Tools/Controller/internal/link/session.go b/Tools/Controller/internal/link/session.go index de8a93ab..c3b2ca58 100644 --- a/Tools/Controller/internal/link/session.go +++ b/Tools/Controller/internal/link/session.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "io" + "log" "net" "net/url" "runtime" @@ -16,6 +17,7 @@ import ( "go.bug.st/serial" "pccontroller.local/controller/internal/native" + "pccontroller.local/controller/internal/playbackpriority" "pccontroller.local/controller/internal/portowner" ) @@ -85,6 +87,10 @@ type Session struct { rfWire rfWireState lastReadReturned atomic.Int64 lastDataReceived atomic.Int64 + // Real local serial sessions attach scheduling to their wire reservation. + // Test/adaptor sessions do not mutate the test runner's process priority. + acquirePlaybackPriority func() (func() error, error) + releaseTimeline func() // guarded by requestAdmission.mu } type sessionPort interface { @@ -176,10 +182,29 @@ func (admission *requestAdmission) acquire( func (s *Session) ReserveTimeline(ctx context.Context) (func(), error) { a := &s.requestAdmission a.mu.Lock() + if err := ctx.Err(); err != nil { + a.mu.Unlock() + return nil, err + } + select { + case <-s.closing: + a.mu.Unlock() + return nil, ErrClosed + default: + } if a.reserved { a.mu.Unlock() return nil, ErrTimelineReserved } + var releasePriority func() error + if s.acquirePlaybackPriority != nil { + var err error + releasePriority, err = s.acquirePlaybackPriority() + if err != nil { + a.mu.Unlock() + return nil, err + } + } a.reserved = true kept := a.waiters[:0] for _, waiter := range a.waiters { @@ -191,15 +216,22 @@ func (s *Session) ReserveTimeline(ctx context.Context) (func(), error) { } } a.waiters = kept - a.mu.Unlock() var once sync.Once release := func() { once.Do(func() { a.mu.Lock() a.reserved = false + s.releaseTimeline = nil + if releasePriority != nil { + if err := releasePriority(); err != nil { + log.Printf("playback scheduling cleanup: %v", err) + } + } a.mu.Unlock() }) } + s.releaseTimeline = release + a.mu.Unlock() drain, err := a.acquire(ctx, s.closing, RequestPriorityTimeline) if err != nil { release() @@ -345,6 +377,7 @@ func OpenContext(ctx context.Context, name string, baudRate int) (*Session, erro } session := NewForPort(name, port) + session.acquirePlaybackPriority = playbackpriority.Acquire return session, nil } @@ -818,6 +851,12 @@ func (s *Session) closeTransport() error { } s.notifyBeforeClose() s.closingOnce.Do(func() { close(s.closing) }) + s.requestAdmission.mu.Lock() + releaseTimeline := s.releaseTimeline + s.requestAdmission.mu.Unlock() + if releaseTimeline != nil { + releaseTimeline() + } if s.pendingOpen != nil { select { case result := <-s.pendingOpen.result: diff --git a/Tools/Controller/internal/link/timeline_reservation_test.go b/Tools/Controller/internal/link/timeline_reservation_test.go index f50c0834..502b7104 100644 --- a/Tools/Controller/internal/link/timeline_reservation_test.go +++ b/Tools/Controller/internal/link/timeline_reservation_test.go @@ -95,3 +95,59 @@ func TestTimelineReservationTimeoutDoesNotStrandAdmission(t *testing.T) { } release() } + +func TestTimelinePriorityLeaseFollowsReservationAndClose(t *testing.T) { + for _, closeFirst := range []bool{false, true} { + session := NewForPort("PRIORITY", newFakePort()) + acquired, released := 0, 0 + session.acquirePlaybackPriority = func() (func() error, error) { acquired++; return func() error { released++; return nil }, nil } + release, err := session.ReserveTimeline(context.Background()) + if err != nil { + t.Fatal(err) + } + if acquired != 1 || released != 0 { + t.Fatal("priority not held by reservation") + } + if closeFirst { + if err = session.Close(); err != nil { + t.Fatal(err) + } + } + release() + release() + if err = session.Close(); err != nil { + t.Fatal(err) + } + if released != 1 { + t.Fatalf("priority released %d times", released) + } + } +} + +func TestTimelinePriorityFailureAndDrainTimeoutUnwind(t *testing.T) { + session := NewForPort("PRIORITY", newFakePort()) + defer session.Close() + failure := errors.New("priority denied") + session.acquirePlaybackPriority = func() (func() error, error) { return nil, failure } + if release, err := session.ReserveTimeline(context.Background()); release != nil || !errors.Is(err, failure) { + t.Fatalf("release present=%v error=%v", release != nil, err) + } + if session.requestAdmission.reserved { + t.Fatal("failed priority acquisition reserved wire") + } + released := 0 + session.acquirePlaybackPriority = func() (func() error, error) { return func() error { released++; return nil }, nil } + active, err := session.requestAdmission.acquire(context.Background(), session.closing, RequestPriorityNormal) + if err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Millisecond) + defer cancel() + if _, err = session.ReserveTimeline(ctx); !errors.Is(err, context.DeadlineExceeded) { + t.Fatal(err) + } + active() + if released != 1 || session.requestAdmission.reserved { + t.Fatal("drain timeout retained priority/wire") + } +} diff --git a/Tools/Controller/internal/playbackpriority/lease.go b/Tools/Controller/internal/playbackpriority/lease.go new file mode 100644 index 00000000..ff36ab4f --- /dev/null +++ b/Tools/Controller/internal/playbackpriority/lease.go @@ -0,0 +1,74 @@ +// Package playbackpriority owns a temporary host scheduling lease, never a +// startup priority policy. Its lifetime is the prepared serial reservation. +package playbackpriority + +import ( + "fmt" + "sync" +) + +const ( + normalClass = uint32(0x20) + highClass = uint32(0x80) + realtimeClass = uint32(0x100) +) + +type manager struct { + mu sync.Mutex + get func() (uint32, error) + set func(uint32) error + refs int + previous uint32 + changed bool +} + +func (m *manager) acquire() (func() error, error) { + m.mu.Lock() + defer m.mu.Unlock() + if m.refs == 0 { + current, err := m.get() + if err != nil { + return nil, fmt.Errorf("read playback scheduling priority: %w", err) + } + // A failed previous restore retains its original baseline for retry. + if !m.changed || current != highClass { + m.changed = false + m.previous = current + } + if current != highClass && current != realtimeClass { + if err := m.set(highClass); err != nil { + return nil, fmt.Errorf("acquire playback scheduling priority: %w", err) + } + m.changed = true + } + } + m.refs++ + var once sync.Once + var releaseErr error + return func() error { + once.Do(func() { + m.mu.Lock() + defer m.mu.Unlock() + m.refs-- + if m.refs != 0 || !m.changed { + return + } + current, err := m.get() + if err != nil { + releaseErr = fmt.Errorf("inspect playback priority restoration: %w", err) + return + } + // Never overwrite a priority change made by another owner. + if current != highClass { + m.changed = false + return + } + if err := m.set(m.previous); err != nil { + releaseErr = fmt.Errorf("restore playback scheduling priority: %w", err) + return + } + m.changed = false + }) + return releaseErr + }, nil +} diff --git a/Tools/Controller/internal/playbackpriority/lease_test.go b/Tools/Controller/internal/playbackpriority/lease_test.go new file mode 100644 index 00000000..9b7d1d99 --- /dev/null +++ b/Tools/Controller/internal/playbackpriority/lease_test.go @@ -0,0 +1,116 @@ +package playbackpriority + +import ( + "errors" + "reflect" + "testing" +) + +func TestLeaseRestoresOriginalOnlyAfterLastOwner(t *testing.T) { + current := normalClass + var writes []uint32 + m := manager{get: func() (uint32, error) { return current, nil }, set: func(p uint32) error { current = p; writes = append(writes, p); return nil }} + a, err := m.acquire() + if err != nil { + t.Fatal(err) + } + b, err := m.acquire() + if err != nil { + t.Fatal(err) + } + if err = a(); err != nil { + t.Fatal(err) + } + if err = a(); err != nil { + t.Fatal(err) + } + if current != highClass { + t.Fatal("first/duplicate release revoked the other owner") + } + if err = b(); err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(writes, []uint32{highClass, normalClass}) { + t.Fatalf("priority transitions %v", writes) + } +} + +func TestLeasePreservesExistingAndExternalPriorities(t *testing.T) { + for _, initial := range []uint32{highClass, realtimeClass} { + writes := 0 + m := manager{get: func() (uint32, error) { return initial, nil }, set: func(uint32) error { writes++; return nil }} + release, err := m.acquire() + if err != nil { + t.Fatal(err) + } + if err = release(); err != nil { + t.Fatal(err) + } + if writes != 0 { + t.Fatal("changed preexisting high/realtime policy") + } + } + current := normalClass + m := manager{get: func() (uint32, error) { return current, nil }, set: func(p uint32) error { current = p; return nil }} + release, err := m.acquire() + if err != nil { + t.Fatal(err) + } + current = 0x4000 // External owner selects BelowNormal during playback. + if err = release(); err != nil { + t.Fatal(err) + } + if current != 0x4000 { + t.Fatal("overwrote external priority change") + } +} + +func TestLeaseAcquisitionFailureDoesNotCreateOwner(t *testing.T) { + failure := errors.New("priority unavailable") + for _, readFailure := range []bool{true, false} { + m := manager{get: func() (uint32, error) { + if readFailure { + return 0, failure + } + return normalClass, nil + }, set: func(uint32) error { return failure }} + release, err := m.acquire() + if !errors.Is(err, failure) || release != nil || m.refs != 0 || m.changed { + t.Fatalf("failure leaked ownership: %+v, %v", m, err) + } + } +} + +func TestFailedRestoreRetainsOriginalBaselineForNextRelease(t *testing.T) { + current := normalClass + failure := errors.New("restore failed") + fail := true + m := manager{get: func() (uint32, error) { return current, nil }, set: func(p uint32) error { + if p == normalClass && fail { + return failure + } + current = p + return nil + }} + release, err := m.acquire() + if err != nil { + t.Fatal(err) + } + if err = release(); !errors.Is(err, failure) { + t.Fatal(err) + } + if !m.changed || m.refs != 0 { + t.Fatal("lost failed restoration ownership") + } + fail = false + release, err = m.acquire() + if err != nil { + t.Fatal(err) + } + if err = release(); err != nil { + t.Fatal(err) + } + if current != normalClass || m.changed { + t.Fatal("failed restore baseline was overwritten") + } +} diff --git a/Tools/Controller/internal/playbackpriority/priority_other.go b/Tools/Controller/internal/playbackpriority/priority_other.go new file mode 100644 index 00000000..bc08d0a4 --- /dev/null +++ b/Tools/Controller/internal/playbackpriority/priority_other.go @@ -0,0 +1,5 @@ +//go:build !windows + +package playbackpriority + +func Acquire() (func() error, error) { return func() error { return nil }, nil } diff --git a/Tools/Controller/internal/playbackpriority/priority_windows.go b/Tools/Controller/internal/playbackpriority/priority_windows.go new file mode 100644 index 00000000..54d1bd31 --- /dev/null +++ b/Tools/Controller/internal/playbackpriority/priority_windows.go @@ -0,0 +1,14 @@ +//go:build windows + +package playbackpriority + +import "golang.org/x/sys/windows" + +var processManager = manager{ + get: func() (uint32, error) { return windows.GetPriorityClass(windows.CurrentProcess()) }, + set: func(priority uint32) error { return windows.SetPriorityClass(windows.CurrentProcess(), priority) }, +} + +// Acquire raises only this process while at least one prepared serial owner +// holds the lease. It never requests realtime priority or changes OS policy. +func Acquire() (func() error, error) { return processManager.acquire() } diff --git a/docs/STRIP-TIMING-TRACE-CHECKPOINT.md b/docs/STRIP-TIMING-TRACE-CHECKPOINT.md index b743f1ed..1be20b54 100644 --- a/docs/STRIP-TIMING-TRACE-CHECKPOINT.md +++ b/docs/STRIP-TIMING-TRACE-CHECKPOINT.md @@ -41,3 +41,11 @@ The kernel trace explains why a long Go write duration is not necessarily a slow Across the four CPUs during that reader-ready interval, about 71.2 CPU-ms went to Pealayer, 53.1 to Remote Desktop Services, 21.3 to antimalware, and 20.5 to the controller. Only about 0.06 CPU-ms was idle. Higher dynamic priorities dominated much of the interval. No services, security controls, affinity, timer resolution or scheduling policy were disabled or persistently changed. The named 16 MB circular Windows capture stopped automatically after 50 seconds, merged successfully, and reported no lost events. The retained interval covers the failing request. ETL, decoded records and process-specific details remain private. This evidence does not support calling the shorter read timeout a driver fix. Next compare one bounded playback run with temporary host priority and automatic restoration; any production scheduling change needs its own tests and normal-load acceptance. Synchronized playback, exact-zero native seek, native modal visual checks, device installation and human output/audio acceptance remain open. + +## Reservation-scoped scheduling candidate + +The owned temporary High-priority comparison passed the short replay from 109.8 through 120.2 seconds: all 205 scheduled steps were acknowledged, maximum ACK lateness was 30.01 ms, maximum round trip 11.11 ms, and maximum restore ACK 4.00 ms. The entire replay was inside the measured priority window; finally restored Normal, paused playback and stopped outputs with unchanged counters. This is one successful diagnostic, not full continuous playback acceptance. + +The next candidate attaches a Windows process-priority lease to the existing prepared serial reservation. A real local serial session acquires it before draining/granting the wire. Failed acquisition refuses readiness; drain timeout, cancellation, ordinary release and connection close unwind it. The controller's existing reservation lifecycle covers pause, fault, expired paused preparation, replacement and shutdown. Renewing the same reservation does not create another priority owner. Multiple owners are reference-counted, duplicate releases are harmless, and the exact prior class is restored only after the last owner. Existing High/Realtime or an externally changed class is preserved; the implementation never requests Realtime. Restoration failures are logged separately and retain the original baseline for a later retry. + +The host remains at its original priority outside prepared playback ownership. No registry, service, timer-resolution or security settings change. Synthetic transport and priority tests use fake backends and do not raise the test runner's priority. Tests cover overlapping ownership, external changes, acquisition/restoration failures, drain timeout, duplicate release and connection-close cleanup. The full link suite and focused playback reservation/expiry/restore regressions are required before hosted build and exact-artifact deployment. A fresh normal-priority process must then pass the short and continuous real-media tests and return to Normal after cleanup. Windows scheduling remains best effort; this lease does not guarantee real-time execution or relax the original deadlines. From 30f95920dcd84f2634c8cec9c00f2a42913d0380 Mon Sep 17 00:00:00 2001 From: DRSDavidSoft <4673812+DRSDavidSoft@users.noreply.github.com> Date: Tue, 27 Oct 2026 04:57:35 +0330 Subject: [PATCH 18/19] Keep priority restoration outside safety admission lock --- Tools/Controller/internal/link/session.go | 4 ++- .../link/timeline_reservation_test.go | 36 +++++++++++++++++++ 2 files changed, 39 insertions(+), 1 deletion(-) diff --git a/Tools/Controller/internal/link/session.go b/Tools/Controller/internal/link/session.go index c3b2ca58..45311a6e 100644 --- a/Tools/Controller/internal/link/session.go +++ b/Tools/Controller/internal/link/session.go @@ -222,12 +222,14 @@ func (s *Session) ReserveTimeline(ctx context.Context) (func(), error) { a.mu.Lock() a.reserved = false s.releaseTimeline = nil + a.mu.Unlock() + // Scheduling cleanup and logging must not block safety admission. + // The process manager retains ownership if another lease overlaps. if releasePriority != nil { if err := releasePriority(); err != nil { log.Printf("playback scheduling cleanup: %v", err) } } - a.mu.Unlock() }) } s.releaseTimeline = release diff --git a/Tools/Controller/internal/link/timeline_reservation_test.go b/Tools/Controller/internal/link/timeline_reservation_test.go index 502b7104..cd063692 100644 --- a/Tools/Controller/internal/link/timeline_reservation_test.go +++ b/Tools/Controller/internal/link/timeline_reservation_test.go @@ -151,3 +151,39 @@ func TestTimelinePriorityFailureAndDrainTimeoutUnwind(t *testing.T) { t.Fatal("drain timeout retained priority/wire") } } + +func TestTimelinePriorityCleanupDoesNotBlockSafetyAdmission(t *testing.T) { + session := NewForPort("PRIORITY", newFakePort()) + defer session.Close() + entered, resume := make(chan struct{}), make(chan struct{}) + session.acquirePlaybackPriority = func() (func() error, error) { + return func() error { close(entered); <-resume; return nil }, nil + } + release, err := session.ReserveTimeline(context.Background()) + if err != nil { + t.Fatal(err) + } + done := make(chan struct{}) + go func() { release(); close(done) }() + <-entered + admitted := make(chan error, 1) + go func() { + releaseSafety, err := session.requestAdmission.acquire(context.Background(), session.closing, RequestPrioritySafety) + if releaseSafety != nil { + releaseSafety() + } + admitted <- err + }() + select { + case err := <-admitted: + close(resume) + <-done + if err != nil { + t.Fatal(err) + } + case <-time.After(time.Second): + close(resume) + <-done + t.Fatal("scheduling cleanup held safety admission") + } +} From 95d618d3a13e6e78606a14c1c6f7f4360b2a10d3 Mon Sep 17 00:00:00 2001 From: DRSDavidSoft <4673812+DRSDavidSoft@users.noreply.github.com> Date: Tue, 27 Oct 2026 05:08:40 +0330 Subject: [PATCH 19/19] Avoid copying mutex in priority test failure diagnostic --- Tools/Controller/internal/playbackpriority/lease_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Tools/Controller/internal/playbackpriority/lease_test.go b/Tools/Controller/internal/playbackpriority/lease_test.go index 9b7d1d99..d01640a8 100644 --- a/Tools/Controller/internal/playbackpriority/lease_test.go +++ b/Tools/Controller/internal/playbackpriority/lease_test.go @@ -76,7 +76,7 @@ func TestLeaseAcquisitionFailureDoesNotCreateOwner(t *testing.T) { }, set: func(uint32) error { return failure }} release, err := m.acquire() if !errors.Is(err, failure) || release != nil || m.refs != 0 || m.changed { - t.Fatalf("failure leaked ownership: %+v, %v", m, err) + t.Fatalf("failure leaked ownership: refs=%d changed=%v error=%v", m.refs, m.changed, err) } } }