gini-toolkit 6.0.1.dev0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (278) hide show
  1. gini/__init__.py +12 -0
  2. gini/__main__.py +107 -0
  3. gini/_version.py +24 -0
  4. gini/agent/__init__.py +17 -0
  5. gini/agent/agent_gamemaster.py +140 -0
  6. gini/agent/api.py +291 -0
  7. gini/agent/ask.py +123 -0
  8. gini/agent/authoring.py +72 -0
  9. gini/agent/blackboard.py +114 -0
  10. gini/agent/contracts.py +142 -0
  11. gini/agent/domains.py +91 -0
  12. gini/agent/embed.py +123 -0
  13. gini/agent/gamemaster.py +256 -0
  14. gini/agent/kb.py +148 -0
  15. gini/agent/lesson_resolver.py +261 -0
  16. gini/agent/llm/__init__.py +5 -0
  17. gini/agent/llm/backend.py +43 -0
  18. gini/agent/llm/fake.py +25 -0
  19. gini/agent/llm/ollama.py +206 -0
  20. gini/agent/loop.py +258 -0
  21. gini/agent/mcp_server.py +86 -0
  22. gini/agent/meaning.py +225 -0
  23. gini/agent/mission.py +210 -0
  24. gini/agent/mission_controller.py +208 -0
  25. gini/agent/narration.py +116 -0
  26. gini/agent/notifier.py +86 -0
  27. gini/agent/personas.py +79 -0
  28. gini/agent/reasoning.py +172 -0
  29. gini/agent/recall.py +248 -0
  30. gini/agent/session.py +79 -0
  31. gini/agent/teaching_center.py +482 -0
  32. gini/agent/tools/__init__.py +3 -0
  33. gini/agent/tools/registry.py +193 -0
  34. gini/agent/twin/__init__.py +28 -0
  35. gini/agent/twin/authoring.py +71 -0
  36. gini/agent/twin/contracts.py +54 -0
  37. gini/agent/twin/dialectic.py +189 -0
  38. gini/agent/twin/harness.py +93 -0
  39. gini/agent/twin/justify.py +156 -0
  40. gini/agent/twin/learner.py +64 -0
  41. gini/agent/twin/mission.py +60 -0
  42. gini/agent/twin/os_coach.py +79 -0
  43. gini/agent/twin/salience.py +30 -0
  44. gini/agent/understand.py +250 -0
  45. gini/agent/verifiers.py +106 -0
  46. gini/agent/wizard.py +178 -0
  47. gini/agent/xv6_pack.py +74 -0
  48. gini/app/__init__.py +3 -0
  49. gini/app/context.py +368 -0
  50. gini/app/paths.py +121 -0
  51. gini/data/README.md +21 -0
  52. gini/domain/__init__.py +9 -0
  53. gini/domain/assembly.py +209 -0
  54. gini/domain/authoring.py +353 -0
  55. gini/domain/blueprints.py +5 -0
  56. gini/domain/capabilities.py +177 -0
  57. gini/domain/catalog.py +85 -0
  58. gini/domain/certify.py +201 -0
  59. gini/domain/compose.py +413 -0
  60. gini/domain/composition.py +88 -0
  61. gini/domain/concepts.py +383 -0
  62. gini/domain/connection_rules.py +269 -0
  63. gini/domain/constraints.py +153 -0
  64. gini/domain/content.py +59 -0
  65. gini/domain/cpu_journey.py +89 -0
  66. gini/domain/devices.py +747 -0
  67. gini/domain/diagnose.py +201 -0
  68. gini/domain/element_guide.py +327 -0
  69. gini/domain/explain.py +90 -0
  70. gini/domain/fingerprint.py +201 -0
  71. gini/domain/firewall.py +34 -0
  72. gini/domain/flowlog.py +61 -0
  73. gini/domain/flowtable.py +179 -0
  74. gini/domain/fragment_yaml.py +230 -0
  75. gini/domain/fragments.py +169 -0
  76. gini/domain/games/__init__.py +2 -0
  77. gini/domain/games/paging_games.py +119 -0
  78. gini/domain/games/policy_game.py +86 -0
  79. gini/domain/games/process_game.py +48 -0
  80. gini/domain/games/thrash_game.py +75 -0
  81. gini/domain/games/translate_game.py +60 -0
  82. gini/domain/games/trap_game.py +86 -0
  83. gini/domain/grader.py +155 -0
  84. gini/domain/grouping.py +67 -0
  85. gini/domain/legality.py +103 -0
  86. gini/domain/lesson.py +241 -0
  87. gini/domain/lexicon.py +150 -0
  88. gini/domain/machine_state.py +410 -0
  89. gini/domain/missions/networking/basic-lan.yaml +32 -0
  90. gini/domain/missions/networking/cache-in-front.yaml +23 -0
  91. gini/domain/missions/networking/decouple-with-queue.yaml +31 -0
  92. gini/domain/missions/networking/drive-load.yaml +20 -0
  93. gini/domain/missions/networking/fix-the-address.yaml +75 -0
  94. gini/domain/missions/networking/fix-the-lan.yaml +43 -0
  95. gini/domain/missions/networking/inspect-flows.yaml +16 -0
  96. gini/domain/missions/networking/k8s-autoscale.yaml +27 -0
  97. gini/domain/missions/networking/least-privilege.yaml +21 -0
  98. gini/domain/missions/networking/load-balanced-web.yaml +29 -0
  99. gini/domain/missions/networking/observe-it.yaml +24 -0
  100. gini/domain/missions/networking/put-in-vpc.yaml +30 -0
  101. gini/domain/missions/networking/reachability-boundary.yaml +56 -0
  102. gini/domain/missions/networking/sdn-reactive.yaml +35 -0
  103. gini/domain/missions/networking/send-request.yaml +19 -0
  104. gini/domain/missions/networking/serverless-api.yaml +25 -0
  105. gini/domain/missions/networking/service-chain.yaml +33 -0
  106. gini/domain/missions/os/lottery-fix.yaml +19 -0
  107. gini/domain/missions/os/priority-fix.yaml +24 -0
  108. gini/domain/missions.py +111 -0
  109. gini/domain/modulechain.py +36 -0
  110. gini/domain/objectives.py +488 -0
  111. gini/domain/os_zoo.py +79 -0
  112. gini/domain/paging_sim.py +141 -0
  113. gini/domain/pricing.py +199 -0
  114. gini/domain/probes.py +226 -0
  115. gini/domain/profile.py +142 -0
  116. gini/domain/recipes.py +738 -0
  117. gini/domain/riders.py +309 -0
  118. gini/domain/router_modules.py +224 -0
  119. gini/domain/routetable.py +67 -0
  120. gini/domain/scoring.py +76 -0
  121. gini/domain/staging.py +122 -0
  122. gini/domain/syscall_builder.py +144 -0
  123. gini/domain/topic_cloud.py +62 -0
  124. gini/domain/topology.py +213 -0
  125. gini/domain/vocabulary.py +51 -0
  126. gini/domain/xv6.py +808 -0
  127. gini/domain/xv6_fs.py +250 -0
  128. gini/domain/xv6_runner.py +113 -0
  129. gini/domain/xv6_vm.py +385 -0
  130. gini/gloader.py +17 -0
  131. gini/runtime/__init__.py +18 -0
  132. gini/runtime/cloudfabric_agent.py +370 -0
  133. gini/runtime/console.py +68 -0
  134. gini/runtime/control.py +70 -0
  135. gini/runtime/frame.py +138 -0
  136. gini/runtime/gbridge.py +638 -0
  137. gini/runtime/grouter.py +223 -0
  138. gini/runtime/hostsim.py +90 -0
  139. gini/runtime/shuttle.py +348 -0
  140. gini/runtime/switch.py +109 -0
  141. gini/runtime/transport.py +77 -0
  142. gini/runtime/xv6_bridge.py +312 -0
  143. gini/server/__init__.py +22 -0
  144. gini/server/__main__.py +74 -0
  145. gini/server/app.py +140 -0
  146. gini/server/auth.py +82 -0
  147. gini/server/policy.py +57 -0
  148. gini/server/session.py +23 -0
  149. gini/services/__init__.py +15 -0
  150. gini/services/boardflash.py +248 -0
  151. gini/services/boardsetup.py +374 -0
  152. gini/services/cloud_catalog.py +143 -0
  153. gini/services/compiler.py +1858 -0
  154. gini/services/discovery.py +324 -0
  155. gini/services/gloader.py +183 -0
  156. gini/services/orchestrator.py +1460 -0
  157. gini/services/persistence.py +28 -0
  158. gini/services/probe_runner.py +149 -0
  159. gini/services/project.py +217 -0
  160. gini/services/remote.py +93 -0
  161. gini/services/rider_runner.py +96 -0
  162. gini/services/rider_session.py +171 -0
  163. gini/services/shadow_store.py +52 -0
  164. gini/services/terminal.py +45 -0
  165. gini/setup/__init__.py +17 -0
  166. gini/setup/cli.py +109 -0
  167. gini/setup/images.py +33 -0
  168. gini/setup/marker.py +43 -0
  169. gini/setup/runtime.py +69 -0
  170. gini/ui/__init__.py +3 -0
  171. gini/ui/assets/app_icon.icns +0 -0
  172. gini/ui/assets/app_icon.ico +0 -0
  173. gini/ui/assets/app_icon.png +0 -0
  174. gini/ui/assets/app_icon_1024.png +0 -0
  175. gini/ui/assets/cue/_w.txt +1 -0
  176. gini/ui/assets/cue/ai.png +0 -0
  177. gini/ui/assets/cue/canvas.png +0 -0
  178. gini/ui/assets/cue/cloud.png +0 -0
  179. gini/ui/assets/cue/cost.png +0 -0
  180. gini/ui/assets/cue/dark/ai.png +0 -0
  181. gini/ui/assets/cue/dark/canvas.png +0 -0
  182. gini/ui/assets/cue/dark/cloud.png +0 -0
  183. gini/ui/assets/cue/dark/cost.png +0 -0
  184. gini/ui/assets/cue/dark/metrics.png +0 -0
  185. gini/ui/assets/cue/dark/router.png +0 -0
  186. gini/ui/assets/cue/dark/run.png +0 -0
  187. gini/ui/assets/cue/dark/serverless.png +0 -0
  188. gini/ui/assets/cue/dark/settings.png +0 -0
  189. gini/ui/assets/cue/dark/welcome.png +0 -0
  190. gini/ui/assets/cue/dark/wizard.png +0 -0
  191. gini/ui/assets/cue/ginibrand/ai.png +0 -0
  192. gini/ui/assets/cue/ginibrand/canvas.png +0 -0
  193. gini/ui/assets/cue/ginibrand/cloud.png +0 -0
  194. gini/ui/assets/cue/ginibrand/cost.png +0 -0
  195. gini/ui/assets/cue/ginibrand/metrics.png +0 -0
  196. gini/ui/assets/cue/ginibrand/router.png +0 -0
  197. gini/ui/assets/cue/ginibrand/run.png +0 -0
  198. gini/ui/assets/cue/ginibrand/serverless.png +0 -0
  199. gini/ui/assets/cue/ginibrand/settings.png +0 -0
  200. gini/ui/assets/cue/ginibrand/welcome.png +0 -0
  201. gini/ui/assets/cue/ginibrand/wizard.png +0 -0
  202. gini/ui/assets/cue/highcontrast/ai.png +0 -0
  203. gini/ui/assets/cue/highcontrast/canvas.png +0 -0
  204. gini/ui/assets/cue/highcontrast/cloud.png +0 -0
  205. gini/ui/assets/cue/highcontrast/cost.png +0 -0
  206. gini/ui/assets/cue/highcontrast/metrics.png +0 -0
  207. gini/ui/assets/cue/highcontrast/router.png +0 -0
  208. gini/ui/assets/cue/highcontrast/run.png +0 -0
  209. gini/ui/assets/cue/highcontrast/serverless.png +0 -0
  210. gini/ui/assets/cue/highcontrast/settings.png +0 -0
  211. gini/ui/assets/cue/highcontrast/welcome.png +0 -0
  212. gini/ui/assets/cue/highcontrast/wizard.png +0 -0
  213. gini/ui/assets/cue/light/ai.png +0 -0
  214. gini/ui/assets/cue/light/canvas.png +0 -0
  215. gini/ui/assets/cue/light/cloud.png +0 -0
  216. gini/ui/assets/cue/light/cost.png +0 -0
  217. gini/ui/assets/cue/light/metrics.png +0 -0
  218. gini/ui/assets/cue/light/router.png +0 -0
  219. gini/ui/assets/cue/light/run.png +0 -0
  220. gini/ui/assets/cue/light/serverless.png +0 -0
  221. gini/ui/assets/cue/light/settings.png +0 -0
  222. gini/ui/assets/cue/light/welcome.png +0 -0
  223. gini/ui/assets/cue/light/wizard.png +0 -0
  224. gini/ui/assets/cue/metrics.png +0 -0
  225. gini/ui/assets/cue/router.png +0 -0
  226. gini/ui/assets/cue/run.png +0 -0
  227. gini/ui/assets/cue/serverless.png +0 -0
  228. gini/ui/assets/cue/settings.png +0 -0
  229. gini/ui/assets/cue/welcome.png +0 -0
  230. gini/ui/assets/cue/wizard.png +0 -0
  231. gini/ui/assistant.py +2111 -0
  232. gini/ui/author_dialog.py +184 -0
  233. gini/ui/board_dialog.py +247 -0
  234. gini/ui/branding.py +21 -0
  235. gini/ui/canvas.py +2007 -0
  236. gini/ui/chat_panel.py +7 -0
  237. gini/ui/cpu_journey.py +212 -0
  238. gini/ui/cpu_lab.py +306 -0
  239. gini/ui/cue_cards.py +214 -0
  240. gini/ui/dashboard.py +222 -0
  241. gini/ui/diagnose_game.py +336 -0
  242. gini/ui/fingerprint_lab.py +219 -0
  243. gini/ui/flash_dialog.py +244 -0
  244. gini/ui/flow_layout.py +63 -0
  245. gini/ui/fragment_manager.py +1415 -0
  246. gini/ui/game_catalog.py +184 -0
  247. gini/ui/game_renderers.py +340 -0
  248. gini/ui/games_lab.py +90 -0
  249. gini/ui/inspector.py +1055 -0
  250. gini/ui/live_metrics.py +130 -0
  251. gini/ui/machine_lab.py +1412 -0
  252. gini/ui/main_window.py +3153 -0
  253. gini/ui/memory_lab.py +371 -0
  254. gini/ui/mission_panel.py +302 -0
  255. gini/ui/mode_indicator.py +227 -0
  256. gini/ui/palette.py +112 -0
  257. gini/ui/peripherals.py +218 -0
  258. gini/ui/process_tree.py +130 -0
  259. gini/ui/reset_dialog.py +179 -0
  260. gini/ui/router_lab.py +776 -0
  261. gini/ui/run_button.py +183 -0
  262. gini/ui/settings_dialog.py +234 -0
  263. gini/ui/signin_dialog.py +111 -0
  264. gini/ui/storage_lab.py +219 -0
  265. gini/ui/syscall_builder.py +235 -0
  266. gini/ui/syscall_lab.py +152 -0
  267. gini/ui/theme/__init__.py +5 -0
  268. gini/ui/theme/icons.py +145 -0
  269. gini/ui/theme/manager.py +291 -0
  270. gini/ui/theme/tokens.py +194 -0
  271. gini/ui/trap_lab.py +270 -0
  272. gini/ui/worker_host.py +102 -0
  273. gini/ui/zoo_lab.py +112 -0
  274. gini_toolkit-6.0.1.dev0.dist-info/METADATA +77 -0
  275. gini_toolkit-6.0.1.dev0.dist-info/RECORD +278 -0
  276. gini_toolkit-6.0.1.dev0.dist-info/WHEEL +5 -0
  277. gini_toolkit-6.0.1.dev0.dist-info/entry_points.txt +3 -0
  278. gini_toolkit-6.0.1.dev0.dist-info/top_level.txt +1 -0
gini/ui/machine_lab.py ADDED
@@ -0,0 +1,1412 @@
1
+ """Machine Lab — the visual OS workbench for an xv6 Machine.
2
+
3
+ Open it by double-clicking an xv6 Machine. The scheduler face is the flagship: four live
4
+ panels — process table, CPU registers, memory (address space / SATP), and the kernel stack —
5
+ that update on each context switch, plus a Gantt strip of which process held the CPU and a
6
+ switch counter. Controls let you slow the time-slice to watch switches happen, Step one
7
+ context switch at a time, or free-run.
8
+
9
+ The lab reads state through an injected `provider` (the same Snapshot shape whether it comes
10
+ from QEMU's GDB stub on the Mac or the offline DemoScheduler here), so all of the UI is
11
+ testable offscreen against a fake feed — the same split as the Router Lab.
12
+
13
+ Mirror of the Router Lab: there you step a *packet* through a pipeline; here you step the
14
+ *CPU* through context switches.
15
+ """
16
+ from __future__ import annotations
17
+
18
+ from PySide6.QtCore import Qt, QTimer, Signal
19
+ from PySide6.QtGui import QColor, QPainter
20
+ from PySide6.QtWidgets import (
21
+ QCheckBox, QComboBox, QDialog, QFrame, QGridLayout, QHBoxLayout, QHeaderView, QLabel, QLineEdit,
22
+ QPlainTextEdit, QPushButton, QScrollArea, QSizePolicy, QSlider, QSpinBox, QStackedWidget,
23
+ QTableWidget, QTableWidgetItem, QVBoxLayout, QWidget,
24
+ )
25
+
26
+ from ..domain.machine_state import MachineState
27
+ from ..domain.xv6 import DemoScheduler, policy_name, ready_queue, short_pid
28
+ from .theme import ThemeManager, icons
29
+ from .theme.manager import scale_css as _scss
30
+
31
+ # scheduler policies the selector offers (must match domain POLICY_NAMES / kernel gini_pick)
32
+ _POLICIES = ["round-robin", "priority", "lottery"]
33
+
34
+ # long-running programs the launcher offers (must match the agent's PROGRAMS list)
35
+ _LAUNCHABLE = ["spin", "busy", "alloc", "writer", "grind", "forktest"]
36
+
37
+
38
+ def _pid_color(pid) -> str:
39
+ """A UNIQUE, well-separated colour per pid — golden-angle hue rotation, so no two nearby
40
+ pids share a colour (the old 6-colour cycle collided constantly)."""
41
+ if pid is None:
42
+ return "#666666"
43
+ import colorsys
44
+ h = ((pid * 137.508) % 360) / 360.0 # golden angle -> maximally spread hues
45
+ r, g, b = colorsys.hsv_to_rgb(h, 0.58, 0.88)
46
+ return "#%02x%02x%02x" % (int(r * 255), int(g * 255), int(b * 255))
47
+
48
+
49
+ class LayerCard(QFrame):
50
+ """A clickable card in the layered overview: title + one-line description + a live mini-stat.
51
+ Cards are grouped into OS layers (user / syscall interface / kernel / hardware) so the front
52
+ door reads like the textbook stack — the student drills into a component instead of being
53
+ dropped straight into the dense scheduler view."""
54
+
55
+ clicked = Signal()
56
+
57
+ def __init__(self, theme, title, desc, accent_key) -> None:
58
+ super().__init__()
59
+ self.theme = theme
60
+ self._accent = accent_key
61
+ t = theme.theme
62
+ acc = t.accent_for(accent_key)
63
+ self.setCursor(Qt.PointingHandCursor)
64
+ self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Preferred)
65
+ self.setMinimumHeight(92)
66
+ self.setStyleSheet(
67
+ f"LayerCard{{background:{t.panel};border:1px solid {t.line};border-radius:12px;}}"
68
+ f"LayerCard:hover{{border-color:{acc};background:{t.panel2};}}")
69
+ v = QVBoxLayout(self); v.setContentsMargins(12, 10, 12, 10); v.setSpacing(3)
70
+ row = QHBoxLayout(); row.setSpacing(6)
71
+ dot = QLabel("●"); dot.setStyleSheet(f"color:{acc};font-size:11px;border:none;")
72
+ ttl = QLabel(title)
73
+ ttl.setStyleSheet(_scss(f"color:{t.text};font-size:14px;font-weight:600;border:none;"))
74
+ row.addWidget(dot); row.addWidget(ttl); row.addStretch(1)
75
+ v.addLayout(row)
76
+ d = QLabel(desc); d.setWordWrap(True)
77
+ d.setStyleSheet(_scss(f"color:{t.muted};font-size:11px;border:none;"))
78
+ v.addWidget(d)
79
+ v.addStretch(1)
80
+ self.stat = QLabel("")
81
+ self.stat.setStyleSheet(
82
+ _scss(f"color:{acc};font-family:monospace;font-size:12px;font-weight:600;border:none;"))
83
+ v.addWidget(self.stat)
84
+
85
+ def set_stat(self, text) -> None:
86
+ self.stat.setText(text or "")
87
+
88
+ def mouseReleaseEvent(self, e) -> None: # noqa: N802
89
+ if e.button() == Qt.LeftButton and self.rect().contains(e.position().toPoint()):
90
+ self.clicked.emit()
91
+ super().mouseReleaseEvent(e)
92
+
93
+
94
+ class SchedulingPanel(QWidget):
95
+ """The scheduler's decision, made legible: the READY QUEUE (who's waiting, ordered the way the
96
+ policy favours) plus the CPU SHARE each process actually got. The Gantt shows who-ran-over-time;
97
+ this shows who's-next-and-why + how fairly the CPU was split — the evidence a lottery/priority
98
+ assignment is graded on. Read-only: students change priority/tickets from their own program."""
99
+
100
+ def __init__(self, theme: ThemeManager) -> None:
101
+ super().__init__()
102
+ self.theme = theme
103
+ t = theme.theme
104
+ v = QVBoxLayout(self); v.setContentsMargins(0, 0, 0, 0); v.setSpacing(4)
105
+ self._tbl = QTableWidget(0, 6)
106
+ self._tbl.setHorizontalHeaderLabels(["pid", "name", "pri", "tickets", "wait", "CPU share"])
107
+ self._tbl.verticalHeader().setVisible(False)
108
+ self._tbl.setEditTriggers(QTableWidget.NoEditTriggers)
109
+ self._tbl.setSelectionMode(QTableWidget.NoSelection)
110
+ self._tbl.horizontalHeader().setStretchLastSection(True)
111
+ self._tbl.setStyleSheet(
112
+ f"QTableWidget{{background:{t.panel};color:{t.text};border:none;"
113
+ f"gridline-color:{t.line};font-family:monospace;font-size:11px;}}"
114
+ f"QHeaderView::section{{background:{t.panel2};color:{t.muted};border:none;"
115
+ "padding:3px;font-family:sans-serif;}")
116
+ v.addWidget(self._tbl)
117
+
118
+ @staticmethod
119
+ def _bar(frac: float, width: int = 8) -> str:
120
+ fill = max(0, min(width, round(frac * width)))
121
+ return "█" * fill + "░" * (width - fill) + f" {frac * 100:.0f}%"
122
+
123
+ def update_view(self, procs, shares) -> None:
124
+ t = self.theme.theme
125
+ running = [p for p in procs if p.state == "running"]
126
+ rows = running + ready_queue(procs) # running first, then the ready queue
127
+ self._tbl.setRowCount(len(rows))
128
+ for i, p in enumerate(rows):
129
+ share = shares.get(p.pid, 0.0)
130
+ vals = [str(p.pid), p.name,
131
+ "—" if p.priority is None else str(p.priority),
132
+ "—" if p.tickets is None else str(p.tickets),
133
+ "—" if p.wait_ticks is None else str(p.wait_ticks),
134
+ self._bar(share)]
135
+ for c, val in enumerate(vals):
136
+ item = QTableWidgetItem(val)
137
+ if p.state == "running":
138
+ item.setForeground(QColor(t.accent_for("green")))
139
+ self._tbl.setItem(i, c, item)
140
+
141
+
142
+ class GanttStrip(QWidget):
143
+ """A horizontal strip of recent scheduling slots — one cell per snapshot, coloured by the
144
+ running pid, so context switches read as colour changes across time."""
145
+
146
+ def __init__(self, theme, label="") -> None:
147
+ super().__init__()
148
+ self.theme = theme
149
+ self.label = label # e.g. "CPU 0" (shown at the left on SMP)
150
+ self._slots: list = []
151
+ self.setMinimumHeight(46)
152
+
153
+ def set_slots(self, slots) -> None:
154
+ self._slots = list(slots)
155
+ self.update()
156
+
157
+ def paintEvent(self, _e) -> None:
158
+ p = QPainter(self)
159
+ t = self.theme.theme
160
+ h = self.height()
161
+ p.fillRect(self.rect(), QColor(t.panel2))
162
+ left = 44 if self.label else 4 # left gutter for the "CPU i" label
163
+ right = 96 # right gutter for the running-pid text
164
+ if self.label:
165
+ p.setPen(QColor(t.muted))
166
+ p.drawText(0, 0, 40, h, Qt.AlignVCenter | Qt.AlignRight, self.label)
167
+ area = self.width() - left - right
168
+ if not self._slots or area <= 4:
169
+ p.setPen(QColor(t.muted))
170
+ p.drawText(self.rect().adjusted(left, 0, 0, 0), Qt.AlignCenter, "— press Run —")
171
+ return
172
+ n = len(self._slots)
173
+ w = max(2.0, area / max(n, 1))
174
+ for i, s in enumerate(self._slots): # the colour band fills the full strip height
175
+ x = left + int(i * w)
176
+ p.fillRect(x, 3, int(w) + 1, h - 6, QColor(_pid_color(s.pid)))
177
+ if w >= 16 and s.pid is not None and (i == 0 or self._slots[i - 1].pid != s.pid):
178
+ p.setPen(QColor("#111111"))
179
+ # last two digits, so a narrow segment never centre-clips a full pid to a
180
+ # misleading middle (3615 & 3613 both showed '61'); full pid is in the right gutter.
181
+ p.drawText(x, 3, int(w), h - 6, Qt.AlignCenter, short_pid(s.pid))
182
+ last = self._slots[-1] # current pid in the reserved right gutter
183
+ p.setPen(QColor(t.text))
184
+ who = "idle" if last.pid is None else f"pid {last.pid}"
185
+ if h >= 40 and last.name:
186
+ who += f" {last.name}"
187
+ p.drawText(self.width() - right + 6, 0, right - 8, h, Qt.AlignVCenter | Qt.AlignLeft, who)
188
+
189
+
190
+ class MachineLab(QDialog):
191
+ """Scheduler face of an xv6 Machine (the only face today; linux/kata later)."""
192
+
193
+ snap_ready = Signal(object) # a Snapshot pushed from a worker thread (live mode)
194
+ load_result = Signal(bool, str, str) # (ok, log, action) from a Load/Revert worker thread
195
+ shadows_ready = Signal(object) # {name: ShadowStatus} from a worker thread
196
+
197
+ def __init__(self, parent, theme: ThemeManager, device, state: MachineState | None = None,
198
+ live=False, on_console=None, on_log=None) -> None:
199
+ super().__init__(parent)
200
+ self.theme = theme
201
+ self.device = device
202
+ self.on_console = on_console
203
+ self.on_log = on_log # (level, message) -> mirror to the GINI Console
204
+ self._shadows: dict = {} # last shadow manifest (name -> ShadowStatus)
205
+ self._shadow_present_seen = False # for the "shadow detected" one-shot console line
206
+ self._reenable_shadow = None # after a Load, re-run the shadow once the kernel is back
207
+ # The shared MachineState is the bridge (owns provider + timeline + watcher); the Lab
208
+ # only renders from it. With no state we spin up a demo-backed one so the lab is explorable.
209
+ self.state = state or MachineState(
210
+ DemoScheduler(timeslice=int((device.properties or {}).get("Timeslice", "1") or "1")),
211
+ device_id=getattr(device, "id", ""), mode="demo")
212
+ # `live` (behaviour: async reads, launcher, poll-vs-step) follows the DATA MODE, which is
213
+ # a user choice on the state — Real means live kernel, Demo means the stand-in feed.
214
+ self.live = (self.state.mode == "real")
215
+ self._running = False
216
+
217
+ t = theme.theme
218
+ self.setWindowTitle(f"Machine Lab — {device.name}")
219
+ self.resize(940, 700)
220
+ self.setStyleSheet(f"QDialog{{background:{t.bg};}}")
221
+
222
+ root = QVBoxLayout(self)
223
+ self._build_topbar(root)
224
+
225
+ # Two rooms behind one door: the layered OVERVIEW (page 0, shown first) and the dense
226
+ # SCHEDULER view (page 1). The scheduler page is built eagerly so its widgets exist for
227
+ # rendering + tests even while the overview is on screen.
228
+ self._stack = QStackedWidget()
229
+ root.addWidget(self._stack, 1)
230
+
231
+ self._sched_page = QWidget()
232
+ spl = QVBoxLayout(self._sched_page); spl.setContentsMargins(0, 0, 0, 0)
233
+ shdr = QHBoxLayout() # title header, consistent with the other Labs' windows
234
+ sic = QLabel(); sic.setPixmap(icons.render_pixmap("host", t.accent_for("red"), 22))
235
+ stitle = QLabel(f" Process Scheduler Lab — {device.name}")
236
+ stitle.setStyleSheet(_scss(f"color:{t.text};font-size:16px;font-weight:600;"))
237
+ shdr.addWidget(sic); shdr.addWidget(stitle); shdr.addStretch(1)
238
+ spl.addLayout(shdr)
239
+ self._build_banner(spl) # shown in Real mode when there's no live data (never fake)
240
+ self._build_controls(spl)
241
+ if self.live:
242
+ self._build_launcher(spl) # launch/kill programs to give the scheduler real work
243
+ self._build_sched_controls(spl) # per-proc priority/tickets (control-plane) setters
244
+ self._build_shadow_bar(spl) # the shadow: toggle + Load/Revert + inline result
245
+ self._build_panels(spl)
246
+
247
+ self._overview = self._build_overview()
248
+ self._stack.addWidget(self._overview) # index 0 — the hub is the permanent Machine Lab
249
+ # the scheduler page opens in its OWN window (like every other card) so the hub stays up and
250
+ # subsystems can be open concurrently; built eagerly (widgets exist for rendering + tests),
251
+ # reparented into its window on first open.
252
+ self._sched_win = None
253
+
254
+ self._busy = False
255
+ self._closed = False # set on close; guards worker-thread callbacks
256
+ self.snap_ready.connect(self._on_snap)
257
+ self.load_result.connect(self._on_load_result)
258
+ self.shadows_ready.connect(self._on_shadows)
259
+ self._poll = QTimer(self)
260
+ self._poll.timeout.connect(self._on_poll)
261
+ self._ov_poll = QTimer(self) # slow refresh so overview mini-stats stay live
262
+ self._ov_poll.timeout.connect(self._on_ov_poll)
263
+ self._shadow_poll = QTimer(self) # slow poll for shadow status (catches file edits)
264
+ self._shadow_poll.timeout.connect(self._on_shadow_poll)
265
+
266
+ self._show_overview()
267
+ self._render() # initial paint (state's first snapshot is taken when it's created)
268
+ if self.live:
269
+ self._fetch(step=False) # kick an async read so the stats/table populate on open
270
+
271
+ # -- top bar (persistent across pages) -------------------------------- #
272
+ def _build_topbar(self, root) -> None:
273
+ t = self.theme.theme
274
+ head = QHBoxLayout()
275
+ self._back_btn = QPushButton(" ← Overview")
276
+ self._back_btn.setStyleSheet(self._btn_css())
277
+ self._back_btn.clicked.connect(self._show_overview)
278
+ self._back_btn.setVisible(False)
279
+ head.addWidget(self._back_btn)
280
+ ic = QLabel(); ic.setPixmap(icons.render_pixmap("host", t.accent_for("red"), 24))
281
+ self._title_lbl = QLabel(f" Machine Lab — {self.device.name}")
282
+ self._title_lbl.setStyleSheet(_scss(f"color:{t.text};font-size:16px;font-weight:600;"))
283
+ head.addWidget(ic); head.addWidget(self._title_lbl); head.addStretch(1)
284
+ # Real/Demo is a user choice (never auto-switched). Real = live kernel; Demo = the stand-in
285
+ # feed for exploring the interface with nothing running.
286
+ from PySide6.QtWidgets import QButtonGroup
287
+ dlbl = QLabel("data"); dlbl.setStyleSheet(_scss(f"color:{t.faint};font-size:11px;"))
288
+ head.addWidget(dlbl)
289
+ self._mode_group = QButtonGroup(self); self._mode_group.setExclusive(True)
290
+ self._mode_btns = {}
291
+ for key, label in (("real", "Real"), ("demo", "Demo")):
292
+ b = QPushButton(label); b.setCheckable(True)
293
+ b.setChecked(self.state.mode == key)
294
+ b.setStyleSheet(self._mode_btn_css())
295
+ b.clicked.connect(lambda _c=False, k=key: self._set_data_mode(k))
296
+ self._mode_group.addButton(b); self._mode_btns[key] = b
297
+ head.addWidget(b)
298
+ root.addLayout(head)
299
+
300
+ def _build_banner(self, root) -> None:
301
+ """A 'no live data' banner for Real mode with nothing running — an explicit error that
302
+ offers Demo, instead of silently painting fake data."""
303
+ t = self.theme.theme
304
+ self._banner = QFrame()
305
+ self._banner.setStyleSheet(
306
+ f"QFrame{{background:{t.panel2};border:1px solid {t.accent_for('amber')};"
307
+ "border-radius:10px;}")
308
+ lay = QHBoxLayout(self._banner); lay.setContentsMargins(12, 8, 12, 8)
309
+ self._banner_lbl = QLabel()
310
+ self._banner_lbl.setWordWrap(True)
311
+ self._banner_lbl.setStyleSheet(_scss(f"color:{t.text};font-size:12px;border:none;"))
312
+ lay.addWidget(self._banner_lbl, 1)
313
+ use_demo = QPushButton(" Use Demo")
314
+ use_demo.setStyleSheet(self._btn_css())
315
+ use_demo.clicked.connect(lambda: self._set_data_mode("demo"))
316
+ lay.addWidget(use_demo)
317
+ self._banner.setVisible(False)
318
+ root.addWidget(self._banner)
319
+
320
+ def _mode_btn_css(self) -> str:
321
+ t = self.theme.theme
322
+ return (f"QPushButton{{color:{t.muted};background:{t.panel2};border:1px solid {t.line};"
323
+ f"border-radius:8px;padding:4px 12px;font-size:12px;}}"
324
+ f"QPushButton:checked{{color:{t.accent_for('green')};border-color:{t.accent};"
325
+ f"background:{t.panel};font-weight:600;}}"
326
+ f"QPushButton:hover{{border-color:{t.accent};}}")
327
+
328
+ def _set_data_mode(self, mode: str) -> None:
329
+ """User flipped Real/Demo. Swap the state's data plane, realign behaviour, and repaint.
330
+ NEVER called automatically — the state also never auto-falls-back, so Real stays Real."""
331
+ if self._running:
332
+ self._toggle_run() # stop the poll loop before swapping the source
333
+ self.state.set_mode(mode)
334
+ self.live = (mode == "real")
335
+ for k, b in self._mode_btns.items():
336
+ b.setChecked(k == mode)
337
+ self._render()
338
+ if self.live and self.state.has_real():
339
+ self._fetch(step=False) # kick a live read on entering Real
340
+
341
+ # -- overview: the layered OS stack of drill-down cards --------------- #
342
+ def _build_overview(self) -> QWidget:
343
+ t = self.theme.theme
344
+ page = QWidget()
345
+ outer = QVBoxLayout(page); outer.setContentsMargins(0, 0, 0, 0)
346
+ intro = QLabel("The xv6 machine, layer by layer. Pick a component to open it — you drill "
347
+ "into detail as you need it.")
348
+ intro.setWordWrap(True)
349
+ intro.setStyleSheet(_scss(f"color:{t.muted};font-size:12px;"))
350
+ outer.addWidget(intro)
351
+
352
+ scroll = QScrollArea(); scroll.setWidgetResizable(True)
353
+ scroll.setFrameShape(QFrame.Shape.NoFrame)
354
+ scroll.setStyleSheet("QScrollArea{border:none;background:transparent;}")
355
+ body = QWidget(); col = QVBoxLayout(body); col.setContentsMargins(2, 2, 2, 2)
356
+ col.setSpacing(6)
357
+ self._ov_cards: dict = {}
358
+
359
+ # top of the stack: what the student's programs are, running in user mode
360
+ col.addWidget(self._layer_band("USER SPACE", [
361
+ ("programs", "Programs & Shell", "The processes you launch — running in user mode.",
362
+ "green", self._open_console),
363
+ ("games", "Games", "Diagnose-from-the-signature challenges.",
364
+ "purple", self._open_games)]))
365
+ col.addWidget(self._boundary("ecall ▾ trap into the kernel · sret ▴ back to user"))
366
+ # the system-call interface: the door between user and kernel
367
+ col.addWidget(self._layer_band("SYSTEM-CALL INTERFACE", [
368
+ ("syscalls", "System Calls", "Live histogram (last 60s) + strace-style trace.",
369
+ "blue", self._open_syscall_lab),
370
+ ("builder", "Syscall Builder", "Add your own syscall — real kernel edits generated.",
371
+ "red", self._open_syscall_builder),
372
+ ("fingerprints", "Process Fingerprints",
373
+ "Each process's behavioral signature + a classify game.",
374
+ "purple", self._open_fingerprints)]))
375
+ col.addWidget(self._boundary("supervisor mode · the kernel"))
376
+ # the kernel's core subsystems
377
+ col.addWidget(self._layer_band("KERNEL", [
378
+ ("scheduler", "Process Scheduler", "Watch the CPU move between processes.",
379
+ "red", self._show_scheduler),
380
+ ("memory", "Virtual Memory", "Page tables, the allocator, and page faults.",
381
+ "purple", self._open_memory_lab),
382
+ ("storage", "File System", "Inodes, buffer cache, and the write-ahead log.",
383
+ "cyan", self._open_storage_lab),
384
+ ("journey", "Traps & Interrupts", "Live trap mix (syscall/fault/timer) + step one.",
385
+ "amber", self._open_trap_lab)]))
386
+ col.addWidget(self._boundary("registers · trapframe · timer interrupts"))
387
+ # the hardware the kernel drives
388
+ col.addWidget(self._layer_band("HARDWARE", [
389
+ ("cpu", "CPU & Registers", "Per-core registers, satp, and the kernel stack.",
390
+ "red", self._open_cpu)]))
391
+ col.addStretch(1)
392
+ scroll.setWidget(body)
393
+ outer.addWidget(scroll, 1)
394
+ return page
395
+
396
+ def _layer_band(self, name, cards) -> QFrame:
397
+ t = self.theme.theme
398
+ band = QFrame()
399
+ band.setStyleSheet(
400
+ f"QFrame{{background:{t.panel2};border:1px solid {t.line};border-radius:12px;}}")
401
+ v = QVBoxLayout(band); v.setContentsMargins(12, 8, 12, 12); v.setSpacing(8)
402
+ lbl = QLabel(name)
403
+ lbl.setStyleSheet(_scss(f"color:{t.faint};font-size:10px;font-weight:700;"
404
+ "letter-spacing:2px;border:none;"))
405
+ v.addWidget(lbl)
406
+ row = QHBoxLayout(); row.setSpacing(8)
407
+ for key, title, desc, accent, handler in cards:
408
+ card = LayerCard(self.theme, title, desc, accent)
409
+ card.clicked.connect(handler)
410
+ self._ov_cards[key] = card
411
+ row.addWidget(card)
412
+ v.addLayout(row)
413
+ return band
414
+
415
+ def _boundary(self, text) -> QLabel:
416
+ t = self.theme.theme
417
+ lbl = QLabel(text); lbl.setAlignment(Qt.AlignCenter)
418
+ lbl.setStyleSheet(_scss(f"color:{t.faint};font-size:10px;letter-spacing:1px;"))
419
+ return lbl
420
+
421
+ # -- page navigation -------------------------------------------------- #
422
+ def _show_overview(self) -> None:
423
+ self._stack.setCurrentWidget(self._overview)
424
+ self._back_btn.setVisible(False) # vestigial now the hub is permanent
425
+ self._title_lbl.setText(f" Machine Lab — {self.device.name}")
426
+ if self.live:
427
+ self._ov_poll.start(2000) # keep the mini-stats fresh while the hub is up
428
+ self._update_overview()
429
+
430
+ def _ensure_sched_window(self) -> None:
431
+ """Lazily wrap the eagerly-built scheduler page in its own window (reparents it once)."""
432
+ if self._sched_win is not None:
433
+ return
434
+ from PySide6.QtWidgets import QDialog
435
+ t = self.theme.theme
436
+ w = QDialog(self)
437
+ w.setWindowTitle(f"Process Scheduler Lab — {self.device.name}")
438
+ w.resize(940, 700)
439
+ w.setStyleSheet(f"QDialog{{background:{t.bg};}}")
440
+ lay = QVBoxLayout(w); lay.setContentsMargins(10, 10, 10, 10)
441
+ lay.addWidget(self._sched_page)
442
+ w.finished.connect(lambda _r=0: self._shadow_poll.stop())
443
+ self._sched_win = w
444
+
445
+ def _show_scheduler(self) -> None:
446
+ # opens the scheduler in its OWN window; the hub (and its mini-stat poll) stays live, so the
447
+ # scheduler can be open alongside Memory/CPU/etc.
448
+ self._ensure_sched_window()
449
+ self._sched_win.show()
450
+ self._sched_win.raise_()
451
+ if self.live:
452
+ self._shadow_poll.start(3000) # keep the shadow status fresh; catch external edits
453
+ self._refresh_shadows()
454
+
455
+ def _on_ov_poll(self) -> None:
456
+ """Slow overview refresh: read current state (live) so the cards' mini-stats update."""
457
+ if self.live and not self._closed:
458
+ self._fetch(step=False)
459
+
460
+ def _require_data(self) -> bool:
461
+ """Guard the data-driven faces: in Real mode with nothing running there's no provider to
462
+ read, so surface the banner (on the scheduler page) instead of opening an empty dialog."""
463
+ if self.state.mode == "real" and not self.state.has_real():
464
+ self._show_scheduler() # the banner lives here and explains + offers Demo
465
+ self._update_banner()
466
+ return False
467
+ return True
468
+
469
+ def _open_syscall_lab(self) -> None:
470
+ if not self._require_data():
471
+ return
472
+ from .syscall_lab import SyscallLab
473
+ # live /sc over the serial when running; DemoScheduler.sc() offline
474
+ src = getattr(self.state.provider, "sc", None)
475
+ self._sclab = SyscallLab(self, self.theme, device=self.device,
476
+ sc_source=src if callable(src) else None)
477
+ self._sclab.show(); self._sclab.raise_()
478
+
479
+ def _open_trap_lab(self) -> None:
480
+ # The Traps room: the live trap-cause histogram + feed (observational front), with a
481
+ # "Step a trap" button into the frame-by-frame journey. Not gated on live data — the
482
+ # histogram shows an empty state, but the (authored) journey is always reachable.
483
+ from .trap_lab import TrapLab
484
+ src = getattr(self.state.provider, "traps", None)
485
+ catch = getattr(self.state.provider, "catch_trap", None) # live gdb freeze (Phase 2/4)
486
+ alarms = getattr(self.state.provider, "alarms", None) # sigalarm-lab strip (Phase 3)
487
+ self._traplab = TrapLab(self, self.theme, device=self.device,
488
+ traps_source=src if callable(src) else None,
489
+ catch_source=catch if callable(catch) else None,
490
+ alarm_source=alarms if callable(alarms) else None,
491
+ on_step=self._open_journey,
492
+ on_play=lambda: self._play_game("trap-cause"))
493
+ self._traplab.show(); self._traplab.raise_()
494
+
495
+ def _open_journey(self, frame=None) -> None:
496
+ # Seed the journey with a frozen trap (real scause/sepc/stval + saved regs) when we have
497
+ # one; otherwise fall back to the running proc's registers at the dispatch stage.
498
+ from .cpu_journey import CpuJourney
499
+ cpu = self.state.latest.cpu if (self.state.latest and self.state.latest.cpu) else None
500
+ self._journey = CpuJourney(self, self.theme, device=self.device, cpu=cpu, frame=frame)
501
+ self._journey.show(); self._journey.raise_()
502
+
503
+ def _open_games(self) -> None:
504
+ from .games_lab import GamesLab
505
+ self._games = GamesLab(self, self.theme, self.device, self.state, live=self.live)
506
+ self._games.show()
507
+ self._games.raise_()
508
+
509
+ def _play_game(self, game_id: str) -> None:
510
+ from .games_lab import open_game
511
+ self._game_win = open_game(self, self.theme, self.device, self.state, game_id, self.live)
512
+
513
+ def _open_fingerprints(self) -> None:
514
+ # Cross-cutting behavioral view (syscalls + traps + scheduling). Not gated on live data:
515
+ # in Demo it uses canned fingerprints so the panel + classify game work offline.
516
+ from .fingerprint_lab import FingerprintLab
517
+ self._fplab = FingerprintLab(self, self.theme, self.device, self.state, live=self.live)
518
+ self._fplab.show()
519
+ self._fplab.raise_()
520
+
521
+ def _open_cpu(self) -> None:
522
+ # The HARDWARE face: per-core register file, decoded satp, kernel stack. Distinct from the
523
+ # Process Scheduler (which process runs) — this is the registers the CPU runs *with*.
524
+ if not self._require_data():
525
+ return
526
+ from .cpu_lab import CpuLab
527
+ self._cpulab = CpuLab(self, self.theme, self.device, self.state, live=self.live)
528
+ self._cpulab.show()
529
+ self._cpulab.raise_()
530
+
531
+ def _open_memory_lab(self) -> None:
532
+ if not self._require_data():
533
+ return
534
+ from .memory_lab import MemoryLab
535
+ # render from the shared MachineState's VM reader (demo stand-in or the Mac GDB bridge),
536
+ # so the Memory face and the Ask GINI card see one source.
537
+ self._memory = MemoryLab(self, self.theme, device=self.device, provider=self.state.vm,
538
+ on_play=self._play_game,
539
+ play_games=[("diagnose thrashing", "thrash-diagnose"),
540
+ ("translate an address", "addr-translate")])
541
+ self._memory.show()
542
+ self._memory.raise_()
543
+
544
+ def _open_storage_lab(self) -> None:
545
+ if not self._require_data():
546
+ return
547
+ from .storage_lab import StorageLab
548
+ self._storage = StorageLab(self, self.theme, device=self.device, provider=self.state.fs)
549
+ self._storage.show()
550
+ self._storage.raise_()
551
+
552
+ def _open_syscall_builder(self) -> None:
553
+ from .syscall_builder import SyscallBuilder
554
+ # If the live provider knows how to write+recompile (Mac-side), let Apply drive it;
555
+ # offline the builder still generates and previews the exact code.
556
+ apply_fn = getattr(self.state.provider, "apply_syscall", None)
557
+ self._syscalls = SyscallBuilder(
558
+ self, self.theme, device=self.device,
559
+ on_apply=apply_fn if callable(apply_fn) else None)
560
+ self._syscalls.show()
561
+ self._syscalls.raise_()
562
+
563
+ def _btn_css(self) -> str:
564
+ t = self.theme.theme
565
+ return (f"QPushButton{{color:{t.text};background:{t.panel2};border:1px solid {t.line};"
566
+ f"border-radius:8px;padding:6px 12px;}}"
567
+ f"QPushButton:hover{{border-color:{t.accent};}}"
568
+ f"QPushButton:disabled{{color:{t.faint};}}")
569
+
570
+ # -- controls (time-slice + step/run) --------------------------------- #
571
+ def _build_controls(self, root) -> None:
572
+ t = self.theme.theme
573
+ hint = QLabel("Watch the kernel move the CPU between processes. Slow the time-slice or "
574
+ "Step one context switch at a time to see it happen.")
575
+ hint.setWordWrap(True)
576
+ hint.setStyleSheet(_scss(f"color:{t.muted};font-size:11px;"))
577
+ root.addWidget(hint)
578
+ bar = QFrame(); bar.setStyleSheet(
579
+ _scss(f"QFrame{{background:{t.panel2};border:1px solid {t.line};border-radius:10px;}}"))
580
+ lay = QHBoxLayout(bar); lay.setContentsMargins(12, 8, 12, 8)
581
+
582
+ lbl = QLabel("Time-slice"); lbl.setStyleSheet(_scss(f"color:{t.muted};font-size:12px;"))
583
+ lay.addWidget(lbl)
584
+ self._slice = QSlider(Qt.Horizontal)
585
+ # quantum in timer ticks (~0.5s each) -> 1..10 ticks = 0.5..5s slices.
586
+ self._slice.setMinimum(1); self._slice.setMaximum(10)
587
+ self._slice.setValue(min(self.state.timeslice, 10))
588
+ self._slice.setFixedWidth(180)
589
+ # label tracks the drag smoothly; the actual (blocking, kernel-halting) quantum write
590
+ # happens ONCE on release, off the GUI thread — dragging must not flood the kernel.
591
+ self._slice.valueChanged.connect(self._update_slice_lbl)
592
+ self._slice.sliderReleased.connect(self._apply_slice)
593
+ lay.addWidget(self._slice)
594
+ self._slice_lbl = QLabel(); self._slice_lbl.setStyleSheet(
595
+ _scss(f"color:{t.text};font-size:12px;min-width:64px;"))
596
+ lay.addWidget(self._slice_lbl)
597
+
598
+ # scheduler policy selector — switches RR/priority/lottery LIVE (over the serial). The
599
+ # kernel confirmation next to it shows the policy the kernel actually reports.
600
+ plbl = QLabel(" Policy"); plbl.setStyleSheet(_scss(f"color:{t.muted};font-size:12px;"))
601
+ lay.addWidget(plbl)
602
+ self._policy_combo = QComboBox()
603
+ self._policy_combo.addItems(_POLICIES)
604
+ cur = getattr(self.state, "policy", "round-robin")
605
+ if cur in _POLICIES:
606
+ self._policy_combo.setCurrentText(cur)
607
+ self._policy_combo.setStyleSheet(
608
+ f"QComboBox{{color:{t.text};background:{t.panel};border:1px solid {t.line};"
609
+ "border-radius:6px;padding:3px 8px;}")
610
+ self._policy_combo.currentTextChanged.connect(self._apply_policy) # connect AFTER setting
611
+ lay.addWidget(self._policy_combo)
612
+ self._policy_kernel_lbl = QLabel()
613
+ self._policy_kernel_lbl.setStyleSheet(_scss(f"color:{t.faint};font-size:11px;"))
614
+ lay.addWidget(self._policy_kernel_lbl)
615
+ # in-lab game: read a Gantt, name the policy
616
+ self._policy_play = QPushButton(" Play")
617
+ self._policy_play.setToolTip("Guess-the-scheduler game: name the policy from a timeline")
618
+ self._policy_play.setIcon(icons.icon("robot", t.accent_for("purple"), 14))
619
+ self._policy_play.setStyleSheet(self._btn_css())
620
+ self._policy_play.clicked.connect(lambda: self._play_game("guess-policy"))
621
+ lay.addWidget(self._policy_play)
622
+ lay.addStretch(1)
623
+
624
+ self._step_btn = QPushButton(" Step switch")
625
+ self._step_btn.setIcon(icons.icon("send", t.accent_for("red"), 15))
626
+ self._step_btn.clicked.connect(self._on_step)
627
+ self._step_btn.setStyleSheet(self._btn_css())
628
+ lay.addWidget(self._step_btn)
629
+
630
+ self._run_btn = QPushButton(" Run")
631
+ self._run_btn.setIcon(icons.icon("play", t.accent_for("green"), 15))
632
+ self._run_btn.clicked.connect(self._toggle_run)
633
+ self._run_btn.setStyleSheet(self._btn_css())
634
+ lay.addWidget(self._run_btn)
635
+
636
+ self._switch_lbl = QLabel("0 switches")
637
+ self._switch_lbl.setStyleSheet(_scss(f"color:{t.muted};font-size:12px;min-width:88px;"))
638
+ lay.addWidget(self._switch_lbl)
639
+ root.addWidget(bar)
640
+ self._update_slice_lbl()
641
+
642
+ # one Gantt strip per CPU (SMP). Built on demand from the per-CPU timelines.
643
+ self._gantt_box = QVBoxLayout(); self._gantt_box.setSpacing(3)
644
+ holder = QWidget(); holder.setLayout(self._gantt_box)
645
+ root.addWidget(holder)
646
+ self._gantts: dict = {} # cpu_index -> GanttStrip
647
+ self._gantt = GanttStrip(self.theme) # the default single strip (cpu 0)
648
+ self._gantts[0] = self._gantt
649
+ self._gantt_box.addWidget(self._gantt)
650
+
651
+ def _sync_gantts(self) -> None:
652
+ """Ensure there's a strip per CPU the kernel reports; shrink them when there are many."""
653
+ cpus = sorted(self.state.cpu_timelines) or [0]
654
+ for ci in cpus:
655
+ if ci not in self._gantts:
656
+ g = GanttStrip(self.theme, label=f"CPU {ci}")
657
+ self._gantts[ci] = g
658
+ self._gantt_box.addWidget(g)
659
+ multi = len(cpus) > 1
660
+ for ci, g in self._gantts.items():
661
+ g.label = f"CPU {ci}" if multi else ""
662
+ g.setMinimumHeight(30 if multi else 46)
663
+ g.setMaximumHeight(30 if multi else 46)
664
+ tl = self.state.cpu_timelines.get(ci)
665
+ g.set_slots(tl.recent() if tl else [])
666
+
667
+ def _build_launcher(self, root) -> None:
668
+ t = self.theme.theme
669
+ bar = QFrame(); bar.setStyleSheet(
670
+ _scss(f"QFrame{{background:{t.panel2};border:1px solid {t.line};border-radius:10px;}}"))
671
+ lay = QHBoxLayout(bar); lay.setContentsMargins(12, 6, 12, 6)
672
+ lbl = QLabel("Launch a program"); lbl.setStyleSheet(_scss(f"color:{t.muted};font-size:12px;"))
673
+ lay.addWidget(lbl)
674
+ self._prog_combo = QComboBox()
675
+ self._prog_combo.addItems(_LAUNCHABLE)
676
+ self._prog_combo.setStyleSheet(
677
+ f"QComboBox{{color:{t.text};background:{t.panel};border:1px solid {t.line};"
678
+ "border-radius:6px;padding:3px 8px;}")
679
+ lay.addWidget(self._prog_combo)
680
+ launch = QPushButton(" Launch")
681
+ launch.setIcon(icons.icon("play", t.accent_for("green"), 14))
682
+ launch.setStyleSheet(self._btn_css())
683
+ launch.clicked.connect(self._launch)
684
+ lay.addWidget(launch)
685
+ hint = QLabel("spin = CPU loop · alloc = grows memory · writer = file writes. "
686
+ "Use ✕ in the table to kill one.")
687
+ hint.setStyleSheet(_scss(f"color:{t.faint};font-size:11px;"))
688
+ lay.addWidget(hint); lay.addStretch(1)
689
+ root.addWidget(bar)
690
+
691
+ def _build_sched_controls(self, root) -> None:
692
+ """Per-process priority + ticket setters (control-plane) — so priority/lottery have real
693
+ differences to schedule on. Pick a pid, set its priority (lower = higher) and lottery
694
+ tickets, then Set."""
695
+ t = self.theme.theme
696
+ bar = QFrame(); bar.setStyleSheet(
697
+ _scss(f"QFrame{{background:{t.panel2};border:1px solid {t.line};border-radius:10px;}}"))
698
+ lay = QHBoxLayout(bar); lay.setContentsMargins(12, 6, 12, 6)
699
+ lay.addWidget(QLabel("Scheduling for pid"))
700
+ self._sc_pid = QComboBox(); self._sc_pid.setMinimumWidth(64)
701
+ self._sc_pid.setStyleSheet(
702
+ f"QComboBox{{color:{t.text};background:{t.panel};border:1px solid {t.line};"
703
+ "border-radius:6px;padding:3px 8px;}")
704
+ self._sc_pid.currentIndexChanged.connect(self._load_sched_control)
705
+ lay.addWidget(self._sc_pid)
706
+ lay.addWidget(QLabel("priority"))
707
+ self._sc_prio = QSpinBox(); self._sc_prio.setRange(0, 30); self._sc_prio.setValue(10)
708
+ self._sc_prio.setToolTip("lower = higher priority")
709
+ lay.addWidget(self._sc_prio)
710
+ lay.addWidget(QLabel("tickets"))
711
+ self._sc_tickets = QSpinBox(); self._sc_tickets.setRange(1, 100); self._sc_tickets.setValue(1)
712
+ self._sc_tickets.setToolTip("lottery weight")
713
+ lay.addWidget(self._sc_tickets)
714
+ setb = QPushButton("Set"); setb.setStyleSheet(self._btn_css())
715
+ setb.clicked.connect(self._apply_sched_control)
716
+ lay.addWidget(setb)
717
+ for w in (self._sc_pid, self._sc_prio, self._sc_tickets):
718
+ pass
719
+ for lbl in bar.findChildren(QLabel):
720
+ lbl.setStyleSheet(_scss(f"color:{t.muted};font-size:12px;"))
721
+ lay.addStretch(1)
722
+ root.addWidget(bar)
723
+
724
+ def _sched_user_pids(self) -> list:
725
+ snap = self.state.latest
726
+ return [p for p in (snap.procs if snap else []) if p.pid > 2]
727
+
728
+ def _refresh_sched_pids(self) -> None:
729
+ """Keep the pid dropdown in step with the live process list (preserve the selection)."""
730
+ if not hasattr(self, "_sc_pid"):
731
+ return
732
+ cur = self._sc_pid.currentData()
733
+ procs = self._sched_user_pids()
734
+ wanted = [(f"{p.pid} {p.name}", p.pid) for p in procs]
735
+ have = [(self._sc_pid.itemText(i), self._sc_pid.itemData(i))
736
+ for i in range(self._sc_pid.count())]
737
+ if have == wanted:
738
+ return
739
+ self._sc_pid.blockSignals(True)
740
+ self._sc_pid.clear()
741
+ for text, pid in wanted:
742
+ self._sc_pid.addItem(text, pid)
743
+ if cur is not None: # keep pointing at the same pid if it's still there
744
+ idx = self._sc_pid.findData(cur)
745
+ if idx >= 0:
746
+ self._sc_pid.setCurrentIndex(idx)
747
+ self._sc_pid.blockSignals(False)
748
+ self._load_sched_control()
749
+
750
+ def _load_sched_control(self) -> None:
751
+ """Load the selected pid's current priority/tickets into the spinboxes."""
752
+ pid = self._sc_pid.currentData()
753
+ for p in self._sched_user_pids():
754
+ if p.pid == pid:
755
+ if p.priority is not None:
756
+ self._sc_prio.setValue(p.priority)
757
+ if p.tickets is not None:
758
+ self._sc_tickets.setValue(p.tickets)
759
+ return
760
+
761
+ def _apply_sched_control(self) -> None:
762
+ pid = self._sc_pid.currentData()
763
+ if pid is None:
764
+ return
765
+ prio, tk = self._sc_prio.value(), self._sc_tickets.value()
766
+ self._bg(lambda: (self.state.provider.set_priority(pid, prio),
767
+ self.state.provider.set_tickets(pid, tk)))
768
+
769
+ def _update_slice_lbl(self) -> None:
770
+ v = self._slice.value()
771
+ self._slice_lbl.setText(f"{v} tick{'s' if v != 1 else ''} (~{v * 0.5:.1f}s slice)")
772
+
773
+ _REG_ROWS = ["pc", "sp", "ra", "s0", "satp", "a0", "a7"] # s0 = frame pointer (backtrace lab)
774
+ _MEM_ROWS = ["page table (satp)", "user pc", "stack ptr", "address space"]
775
+
776
+ # -- the four state panels -------------------------------------------- #
777
+ def _build_panels(self, root) -> None:
778
+ grid = QGridLayout(); grid.setSpacing(10)
779
+ from .process_tree import ProcessTree
780
+ self._proc_tree = ProcessTree(self.theme)
781
+ self._proc_tree.set_live(self.live)
782
+ self._proc_tree.kill_requested.connect(self._kill)
783
+ grid.addWidget(self._panel("Processes · tree", self._proc_tree, fill=True), 0, 0)
784
+ # per-CPU tables: one column per CPU (so both cores' registers/memory show on SMP)
785
+ self._reg_tbl = self._make_cpu_table(self._REG_ROWS)
786
+ grid.addWidget(self._panel("CPU registers · per core", self._reg_tbl, fill=True), 0, 1)
787
+ # the scheduler flagship: ready queue + CPU share (the per-core memory view lives behind
788
+ # the Memory card on the hub, so it doesn't need a duplicate mini-panel here).
789
+ self._sched_panel = SchedulingPanel(self.theme)
790
+ grid.addWidget(self._panel("Scheduling · ready queue & CPU share", self._sched_panel,
791
+ fill=True), 1, 0)
792
+ self._stack_lbl = QLabel(); self._stack_lbl.setAlignment(Qt.AlignTop)
793
+ self._stack_lbl.setTextFormat(Qt.RichText)
794
+ t = self.theme.theme
795
+ self._stack_lbl.setStyleSheet(_scss(f"color:{t.text};font-family:monospace;font-size:12px;"))
796
+ grid.addWidget(self._panel("Kernel stack · bt (Step)", self._stack_lbl), 1, 1)
797
+ grid.setColumnStretch(0, 1); grid.setColumnStretch(1, 1)
798
+ root.addLayout(grid, 1)
799
+
800
+ def _make_cpu_table(self, rows) -> QTableWidget:
801
+ t = self.theme.theme
802
+ tbl = QTableWidget(len(rows), 0)
803
+ tbl.setVerticalHeaderLabels(rows)
804
+ tbl.setEditTriggers(QTableWidget.NoEditTriggers)
805
+ tbl.setSelectionMode(QTableWidget.NoSelection)
806
+ tbl.horizontalHeader().setStretchLastSection(True)
807
+ tbl.setStyleSheet(
808
+ f"QTableWidget{{background:{t.panel};color:{t.text};border:none;"
809
+ f"gridline-color:{t.line};font-family:monospace;font-size:11px;}}"
810
+ f"QHeaderView::section{{background:{t.panel2};color:{t.muted};border:none;"
811
+ "padding:3px;font-family:sans-serif;}")
812
+ return tbl
813
+
814
+ def _panel(self, title, inner, fill=False) -> QFrame:
815
+ t = self.theme.theme
816
+ f = QFrame(); f.setStyleSheet(
817
+ _scss(f"QFrame{{background:{t.panel2};border:1px solid {t.line};border-radius:10px;}}"))
818
+ v = QVBoxLayout(f); v.setContentsMargins(10, 8, 10, 10)
819
+ h = QLabel(title); h.setStyleSheet(
820
+ _scss(f"color:{t.muted};font-size:11px;font-weight:600;border:none;"))
821
+ v.addWidget(h)
822
+ inner.setStyleSheet((inner.styleSheet() or "") + "border:none;")
823
+ v.addWidget(inner, 1 if fill else 0)
824
+ if not fill: # keep content pinned to the top, not centered
825
+ v.addStretch(1)
826
+ return f
827
+
828
+ def _make_proc_table(self) -> QTableWidget:
829
+ t = self.theme.theme
830
+ tbl = QTableWidget(0, 4)
831
+ tbl.setHorizontalHeaderLabels(["PID", "State", "Name", ""])
832
+ tbl.verticalHeader().setVisible(False)
833
+ tbl.setEditTriggers(QTableWidget.NoEditTriggers)
834
+ tbl.setSelectionMode(QTableWidget.NoSelection)
835
+ tbl.horizontalHeader().setSectionResizeMode(2, QHeaderView.Stretch)
836
+ tbl.setColumnWidth(3, 44)
837
+ tbl.setStyleSheet(
838
+ f"QTableWidget{{background:{t.panel};color:{t.text};border:none;"
839
+ f"gridline-color:{t.line};font-size:12px;}}"
840
+ f"QHeaderView::section{{background:{t.panel2};color:{t.muted};border:none;"
841
+ "padding:4px;}")
842
+ return tbl
843
+
844
+ def _make_kv_panel(self, keys) -> tuple[dict, QWidget]:
845
+ t = self.theme.theme
846
+ w = QWidget(); g = QGridLayout(w); g.setContentsMargins(0, 4, 0, 0); g.setSpacing(4)
847
+ fields = {}
848
+ for i, k in enumerate(keys):
849
+ kl = QLabel(k); kl.setStyleSheet(_scss(f"color:{t.muted};font-size:11px;border:none;"))
850
+ vl = QLabel("—"); vl.setStyleSheet(
851
+ _scss(f"color:{t.text};font-family:monospace;font-size:12px;border:none;"))
852
+ vl.setTextInteractionFlags(Qt.TextSelectableByMouse)
853
+ g.addWidget(kl, i, 0); g.addWidget(vl, i, 1)
854
+ fields[k] = vl
855
+ g.setColumnStretch(1, 1)
856
+ return fields, w
857
+
858
+ # -- programs: launch / kill (all off the GUI thread) ----------------- #
859
+ def _bg(self, fn) -> None:
860
+ import threading
861
+ threading.Thread(target=fn, daemon=True).start()
862
+
863
+ def _launch(self) -> None:
864
+ prog = self._prog_combo.currentText()
865
+ self._bg(lambda: self._act_then_refresh(lambda: self.state.provider.run(prog)))
866
+
867
+ def _kill(self, pid: int) -> None:
868
+ self._bg(lambda: self._act_then_refresh(lambda: self.state.provider.kill(pid)))
869
+
870
+ def _act_then_refresh(self, action) -> None:
871
+ """Run a serial action (launch/kill), then RE-READ the kernel several times (SEQUENTIALLY,
872
+ each read blocking to completion in this worker thread) so the new/removed process shows
873
+ up — the shell needs a beat to fork+exec (or reap). Sequential (not busy-guarded) so every
874
+ attempt actually fires; empty reads are ignored by MachineState, so nothing blanks."""
875
+ import time
876
+ try:
877
+ action()
878
+ except Exception:
879
+ pass
880
+ for _ in range(6):
881
+ if self._closed:
882
+ return
883
+ time.sleep(0.4)
884
+ try:
885
+ self.state.refresh() # blocks until the read completes (worker thread)
886
+ if not self._closed:
887
+ self.snap_ready.emit(None) # repaint on the GUI thread
888
+ except (Exception, RuntimeError):
889
+ return # dialog closed mid-refresh -> stop quietly
890
+
891
+ def _open_console(self) -> None:
892
+ if self.live and hasattr(self.state.provider, "console"):
893
+ self._console = Xv6Console(self, self.theme, self.state.provider, self.device)
894
+ self._console.show(); self._console.raise_()
895
+ elif self.on_console:
896
+ self.on_console()
897
+ else:
898
+ # no live console (offline demo) — the process tree IS the list of programs
899
+ self._show_scheduler()
900
+
901
+ # -- control handlers ------------------------------------------------- #
902
+ def _apply_slice(self) -> None:
903
+ """Commit the time-slice ONCE, when the slider is released — off the GUI thread for the
904
+ live bridge (the write halts the kernel via gdb, so we must not do it inline or on every
905
+ drag tick)."""
906
+ v = self._slice.value()
907
+ self._update_slice_lbl()
908
+ if self.live:
909
+ self._bg(lambda: self.state.set_timeslice(v))
910
+ else:
911
+ self.state.set_timeslice(v)
912
+
913
+ def _apply_policy(self, name) -> None:
914
+ """Switch the scheduler policy live. For the live bridge the write goes over the serial
915
+ off the GUI thread; offline it re-picks against the demo so the change is visible at once."""
916
+ if self.live:
917
+ self._bg(lambda: self.state.set_policy(name))
918
+ else:
919
+ self.state.set_policy(name)
920
+ self._render()
921
+ if hasattr(self, "_shadow_status"):
922
+ self._update_shadow_bar() # the bar tracks the CURRENT policy's shadow
923
+
924
+ def _sync_policy_combo(self) -> None:
925
+ """Populate the policy selector from the kernel's live roster (POLICY lines) so a policy the
926
+ kernel ships auto-appears — no hardcoded list. Offline / no roster: keep the built-in list."""
927
+ combo = getattr(self, "_policy_combo", None)
928
+ if combo is None or not self.live:
929
+ return
930
+ roster = self.state.policies()
931
+ if not roster:
932
+ return
933
+ names = [roster[i] for i in sorted(roster)]
934
+ if names == [combo.itemText(i) for i in range(combo.count())]:
935
+ return # already in sync
936
+ cur = combo.currentText()
937
+ combo.blockSignals(True) # repopulate without firing _apply_policy
938
+ combo.clear(); combo.addItems(names)
939
+ if cur in names:
940
+ combo.setCurrentText(cur)
941
+ combo.blockSignals(False)
942
+
943
+ # -- the shadow bar: toggle + Load/Revert + inline result (live only) --------- #
944
+ _POLICY_SHADOW = {"round-robin": "rr_sched", "priority": "prio_sched",
945
+ "lottery": "lottery_sched"}
946
+
947
+ def _build_shadow_bar(self, root) -> None:
948
+ t = self.theme.theme
949
+ bar = QFrame(); bar.setStyleSheet(
950
+ _scss(f"QFrame{{background:{t.panel2};border:1px solid {t.line};border-radius:10px;}}"))
951
+ lay = QVBoxLayout(bar); lay.setContentsMargins(12, 8, 12, 8); lay.setSpacing(6)
952
+ row = QHBoxLayout()
953
+ lbl = QLabel("Shadow"); lbl.setStyleSheet(_scss(f"color:{t.muted};font-size:12px;"))
954
+ row.addWidget(lbl)
955
+ self._shadow_status = QLabel("—")
956
+ self._shadow_status.setStyleSheet(
957
+ _scss(f"color:{t.text};font-size:12px;font-family:monospace;"))
958
+ row.addWidget(self._shadow_status); row.addStretch(1)
959
+ self._shadow_toggle = QCheckBox("Use my shadow")
960
+ self._shadow_toggle.setStyleSheet(_scss(f"color:{t.text};font-size:12px;"))
961
+ self._shadow_toggle.toggled.connect(self._toggle_shadow)
962
+ row.addWidget(self._shadow_toggle)
963
+ self._load_btn = QPushButton(" Load")
964
+ self._load_btn.setIcon(icons.icon("compile", t.accent_for("green"), 14))
965
+ self._load_btn.setToolTip("Rebuild the kernel with your shadow and restart")
966
+ self._load_btn.clicked.connect(self._load_shadow)
967
+ self._load_btn.setStyleSheet(self._btn_css())
968
+ row.addWidget(self._load_btn)
969
+ self._revert_btn = QPushButton(" Revert")
970
+ self._revert_btn.setToolTip("Restore the shipped shadow and rebuild")
971
+ self._revert_btn.clicked.connect(self._revert_shadow)
972
+ self._revert_btn.setStyleSheet(self._btn_css())
973
+ row.addWidget(self._revert_btn)
974
+ lay.addLayout(row)
975
+ self._shadow_result = QPlainTextEdit(); self._shadow_result.setReadOnly(True)
976
+ self._shadow_result.setVisible(False)
977
+ lay.addWidget(self._shadow_result)
978
+ root.addWidget(bar)
979
+
980
+ def _current_shadow_name(self) -> str:
981
+ combo = getattr(self, "_policy_combo", None)
982
+ pol = combo.currentText() if combo else "priority"
983
+ return self._POLICY_SHADOW.get(pol, "prio_sched")
984
+
985
+ def _refresh_shadows(self) -> None:
986
+ if not self.live:
987
+ return
988
+ self._bg(self._fetch_shadows)
989
+
990
+ def _fetch_shadows(self) -> None:
991
+ try:
992
+ sh = self.state.shadows()
993
+ except (Exception, RuntimeError):
994
+ return
995
+ if not self._closed:
996
+ self.shadows_ready.emit(sh)
997
+
998
+ def _on_shadow_poll(self) -> None:
999
+ if self.live and not self._closed:
1000
+ self._refresh_shadows()
1001
+
1002
+ def _on_shadows(self, sh) -> None:
1003
+ if self._closed:
1004
+ return
1005
+ self._shadows = sh or {}
1006
+ self._update_shadow_bar()
1007
+ # reconcile a pending "run my shadow after Load": the rebuilt kernel booted on the primary,
1008
+ # so once its manifest is readable again, re-enable the shadow; confirm only when it's active.
1009
+ name = self._reenable_shadow
1010
+ if name:
1011
+ s = self._shadows.get(name)
1012
+ if s is None:
1013
+ return # kernel still booting — retry next poll
1014
+ if s.active:
1015
+ self._reenable_shadow = None
1016
+ self._show_result("✓ Running your shadow", "green")
1017
+ self._log("info", "xv6: your shadow is now running")
1018
+ elif not s.enabled:
1019
+ self._bg(lambda n=name: self._provider_call("set_shadow", n, True))
1020
+
1021
+ def _update_shadow_bar(self) -> None:
1022
+ if not hasattr(self, "_shadow_status"):
1023
+ return
1024
+ t = self.theme.theme
1025
+ name = self._current_shadow_name()
1026
+ s = self._shadows.get(name)
1027
+ if s is None:
1028
+ self._shadow_status.setText(f"{name}: (not reported)")
1029
+ else:
1030
+ if s.active:
1031
+ txt, col = f"{name}: active ✓", t.success
1032
+ elif s.faults:
1033
+ txt, col = f"{name}: faulted {s.faults}×", t.accent_for("red")
1034
+ elif s.enabled:
1035
+ txt, col = f"{name}: on (stub → primary)", t.muted
1036
+ else:
1037
+ txt, col = f"{name}: off (primary running)", t.muted
1038
+ if s.hash and s.hash != "baseline":
1039
+ txt += f" [{s.hash}]"
1040
+ self._shadow_status.setText(txt)
1041
+ self._shadow_status.setStyleSheet(
1042
+ _scss(f"color:{col};font-size:12px;font-family:monospace;"))
1043
+ self._shadow_toggle.blockSignals(True)
1044
+ self._shadow_toggle.setChecked(bool(s.enabled))
1045
+ self._shadow_toggle.blockSignals(False)
1046
+ # "shadow detected" — one-shot console line when the file first differs from the stub
1047
+ present = any(getattr(v, "is_student", False) for v in self._shadows.values())
1048
+ if present and not self._shadow_present_seen:
1049
+ self._log("info", "xv6 shadow detected — gini_sched.c edited (Load to run it)")
1050
+ self._shadow_present_seen = present
1051
+
1052
+ def _toggle_shadow(self, on) -> None:
1053
+ name = self._current_shadow_name()
1054
+ self._bg(lambda: self._provider_call("set_shadow", name, bool(on)))
1055
+ self._refresh_shadows()
1056
+
1057
+ def _provider_call(self, method, *args) -> None:
1058
+ fn = getattr(self.state.provider, method, None)
1059
+ if callable(fn):
1060
+ try:
1061
+ fn(*args)
1062
+ except Exception:
1063
+ pass
1064
+
1065
+ def _load_shadow(self) -> None:
1066
+ self._run_build("load", "Building your shadow…")
1067
+
1068
+ def _revert_shadow(self) -> None:
1069
+ self._run_build("revert", "Reverting to the shipped shadow…")
1070
+
1071
+ def _run_build(self, action, busy_msg) -> None:
1072
+ self._load_btn.setEnabled(False); self._revert_btn.setEnabled(False)
1073
+ self._show_result(busy_msg, "muted")
1074
+
1075
+ def work():
1076
+ fn = getattr(self.state.provider, action, None)
1077
+ ok, log = (fn() if callable(fn) else (False, "no live kernel"))
1078
+ if not self._closed:
1079
+ self.load_result.emit(bool(ok), str(log), action)
1080
+ self._bg(work)
1081
+
1082
+ def _on_load_result(self, ok, log, action) -> None:
1083
+ if self._closed:
1084
+ return
1085
+ self._load_btn.setEnabled(True); self._revert_btn.setEnabled(True)
1086
+ if not ok:
1087
+ self._show_result(log or "build failed", "red")
1088
+ self._log("error", f"xv6 shadow {action} failed — compile error (see Machine Lab)")
1089
+ self._reenable_shadow = None
1090
+ elif action == "revert":
1091
+ # the shipped stub is back; a rebooted kernel boots on the primary
1092
+ self._show_result("✓ Reverted — shipped shadow restored (primary running)", "green")
1093
+ self._log("info", "xv6: reverted to the shipped shadow")
1094
+ self._reenable_shadow = None
1095
+ else: # load — the kernel is rebuilt, but a fresh boot starts on the PRIMARY (shadow off)
1096
+ if self._shadow_toggle.isChecked():
1097
+ # honour the intent: re-run the shadow once the rebooted kernel is back (reconciled
1098
+ # in _on_shadows). Don't claim it's running until it actually is.
1099
+ self._reenable_shadow = self._current_shadow_name()
1100
+ self._show_result("✓ Loaded — bringing up your shadow…", "muted")
1101
+ else:
1102
+ self._reenable_shadow = None
1103
+ self._show_result("✓ Loaded — kernel rebuilt. Tick “Use my shadow” to run it.",
1104
+ "green")
1105
+ self._log("info", "xv6: loaded a rebuilt kernel")
1106
+ if self.live:
1107
+ self._fetch(step=False) # QEMU restarted -> read fresh state
1108
+ self._refresh_shadows()
1109
+
1110
+ def _show_result(self, text, tone) -> None:
1111
+ t = self.theme.theme
1112
+ col = {"green": t.success, "red": t.accent_for("red"),
1113
+ "muted": t.muted}.get(tone, t.text)
1114
+ self._shadow_result.setPlainText(text)
1115
+ self._shadow_result.setVisible(True)
1116
+ multiline = ("\n" in text) or tone == "red"
1117
+ self._shadow_result.setFixedHeight(130 if multiline else 30)
1118
+ self._shadow_result.setStyleSheet(
1119
+ f"QPlainTextEdit{{background:{t.panel};color:{col};border:1px solid {t.line};"
1120
+ "border-radius:6px;font-family:monospace;font-size:11px;padding:4px;}")
1121
+
1122
+ def _log(self, level, msg) -> None:
1123
+ if self.on_log:
1124
+ try:
1125
+ self.on_log(level, msg)
1126
+ except Exception:
1127
+ pass
1128
+
1129
+ def _on_poll(self) -> None:
1130
+ """The Run loop = OBSERVE the free-running kernel: just read current state on a timer.
1131
+ Crucially NOT step() — on a live idle kernel, stepping waits for a context switch that
1132
+ never comes and would hang. The demo has no real kernel, so it advances its round-robin
1133
+ to animate."""
1134
+ if self.live:
1135
+ self._fetch(step=False) # refresh (read), never halt-and-wait
1136
+ else:
1137
+ try:
1138
+ self.state.step() # demo: animate the fake round-robin
1139
+ except Exception:
1140
+ return
1141
+ self._render()
1142
+
1143
+ def _on_step(self) -> None:
1144
+ """The manual 'Step switch' button = advance exactly one context switch (halts on
1145
+ swtch). On a live idle kernel this may time out harmlessly (async, so no freeze)."""
1146
+ if self.live:
1147
+ self._fetch(step=True) # live: read gdb off the GUI thread
1148
+ return
1149
+ try:
1150
+ self.state.step() # demo: instant, run inline
1151
+ except Exception:
1152
+ return
1153
+ self._render()
1154
+
1155
+ def _fetch(self, step: bool) -> None:
1156
+ """Pull a snapshot from the live bridge on a worker thread (gdb-over-HTTP can block for
1157
+ a moment or time out), then repaint on the GUI thread. Skips if a read is in flight so
1158
+ Run/Step can't pile up and freeze the UI."""
1159
+ if self._busy or self._closed:
1160
+ return
1161
+ self._busy = True
1162
+ import threading
1163
+
1164
+ def work():
1165
+ try:
1166
+ self.state.step() if step else self.state.refresh()
1167
+ if not self._closed: # don't signal a dialog that's being torn down
1168
+ self.snap_ready.emit(None) # marshal back to the GUI thread
1169
+ except (Exception, RuntimeError):
1170
+ pass # incl. dialog closed mid-read
1171
+ threading.Thread(target=work, daemon=True).start()
1172
+
1173
+ def _on_snap(self, _obj) -> None:
1174
+ self._busy = False
1175
+ if self._closed:
1176
+ return
1177
+ self._render()
1178
+
1179
+ def _toggle_run(self) -> None:
1180
+ t = self.theme.theme
1181
+ self._running = not self._running
1182
+ if self._running:
1183
+ self._run_btn.setText(" Pause")
1184
+ self._run_btn.setIcon(icons.icon("stop", t.accent_for("amber"), 15))
1185
+ self._poll.start(500 if self.live else 700) # fast procdump reads -> sample often
1186
+ else:
1187
+ self._run_btn.setText(" Run")
1188
+ self._run_btn.setIcon(icons.icon("play", t.accent_for("green"), 15))
1189
+ self._poll.stop()
1190
+
1191
+ # -- render from the shared state ------------------------------------- #
1192
+ def _render(self, *_a) -> None:
1193
+ self._update_banner()
1194
+ snap = self.state.latest
1195
+ if snap is None:
1196
+ self._update_overview() # keep the hub's mini-stats honest (blank) with no data
1197
+ return
1198
+ t = self.theme.theme
1199
+ # process TREE — highlight whatever is running on any CPU (SMP-aware)
1200
+ run = snap.running_pid
1201
+ running_pids = set(snap.cpus.values()) if snap.cpus else ({run} if run else set())
1202
+ # scheduling badges: mark starving / CPU-monopolising procs in the tree
1203
+ sf = self.state.scheduling_flags()
1204
+ flags = {pid: "starving" for pid in sf.get("starvation", ())}
1205
+ for pid in sf.get("cpu_monopoly", ()):
1206
+ flags.setdefault(pid, "monopolising CPU")
1207
+ self._proc_tree.set_procs(snap.procs, running_pids, flags=flags)
1208
+ if hasattr(self, "_sc_pid"):
1209
+ self._refresh_sched_pids() # keep the priority/tickets pid list live
1210
+ # per-CPU registers (a column per core; falls back to one column single-CPU)
1211
+ cpu_regs = snap.cpu_regs or ({0: snap.cpu} if snap.cpu else {})
1212
+ cids = sorted(cpu_regs)
1213
+ self._reg_tbl.setColumnCount(len(cids))
1214
+ heads = [f"CPU {c} · pid {cpu_regs[c].key('pid')}" for c in cids]
1215
+ self._reg_tbl.setHorizontalHeaderLabels(heads)
1216
+ for col, c in enumerate(cids):
1217
+ cs = cpu_regs[c]
1218
+ for row, key in enumerate(self._REG_ROWS):
1219
+ self._reg_tbl.setItem(row, col, QTableWidgetItem(cs.key(key)))
1220
+ # scheduling panel: ready queue + CPU share (share from the aggregate timeline)
1221
+ self._sched_panel.update_view(snap.procs, self.state.timeline.shares())
1222
+ # kernel stack (from gdb on Step; user procs are in user mode during Run)
1223
+ if snap.stack:
1224
+ rows = "<br>".join(
1225
+ f"<span style='color:{t.muted}'>#{i}</span> {f.fn}"
1226
+ + (f" <span style='color:{t.faint}'>{f.loc}</span>" if f.loc else "")
1227
+ for i, f in enumerate(snap.stack))
1228
+ self._stack_lbl.setText(rows)
1229
+ else:
1230
+ self._stack_lbl.setText(
1231
+ f"<span style='color:{t.faint}'>Press <b>Step switch</b> to capture the kernel "
1232
+ "backtrace at a context switch. (Running processes are in user mode.)</span>")
1233
+ # gantt + counter + a switches/second meter (drops as the time-slice grows -> shows the
1234
+ # slider actually taking effect, which coarse sampling otherwise hides)
1235
+ import time as _time
1236
+ if not hasattr(self, "_rate"):
1237
+ import collections
1238
+ self._rate = collections.deque(maxlen=40)
1239
+ self._rate.append((_time.monotonic(), run))
1240
+ _chg = sum(1 for i in range(1, len(self._rate))
1241
+ if self._rate[i][1] != self._rate[i - 1][1])
1242
+ _win = (self._rate[-1][0] - self._rate[0][0]) if len(self._rate) > 1 else 0.0
1243
+ self._switch_rate = (_chg / _win) if _win > 0.5 else 0.0
1244
+ self._sync_gantts() # per-CPU strips (SMP) from the CPU dump lines
1245
+ n = self.state.timeline.switches()
1246
+ kq = getattr(self.state.provider, "kernel_quantum", None) # the kernel's ACTUAL quantum
1247
+ qtxt = f" · Q{kq}" if kq else ""
1248
+ self._switch_lbl.setText(f"{n} sw · {self._switch_rate:.1f}/s{qtxt}")
1249
+ self._sync_policy_combo() # data-driven selector from the kernel roster
1250
+ # policy confirmation: what the kernel actually reports (live id) or the demo's policy
1251
+ kp = getattr(self.state.provider, "kernel_policy", None)
1252
+ kp_name = policy_name(kp) if kp is not None else getattr(
1253
+ self.state.provider, "policy", None) or self.state.policy
1254
+ self._policy_kernel_lbl.setText(f"kernel: {kp_name}")
1255
+ self._update_overview()
1256
+
1257
+ def _update_banner(self) -> None:
1258
+ if not hasattr(self, "_banner"):
1259
+ return
1260
+ no_data = (self.state.mode == "real" and self.state.latest is None)
1261
+ self._banner.setVisible(no_data)
1262
+ if no_data:
1263
+ self._banner_lbl.setText(
1264
+ "No live data from the xv6 machine. It doesn't look like it's running — start the "
1265
+ "topology (Run), or switch to Demo to explore the interface."
1266
+ if not self.state.has_real()
1267
+ else "Real mode is selected but no data has arrived yet — waiting for the xv6 "
1268
+ "machine…")
1269
+
1270
+ def _configured_cores(self) -> int:
1271
+ """The number of cores QEMU was launched with (derived from the Size tier), so the count is
1272
+ correct even when cores are idle — idle cores emit no per-core dump line, which is why a
1273
+ live-sample would read '1 core'."""
1274
+ try:
1275
+ from ..services.compiler import _xv6_harts
1276
+ return max(1, _xv6_harts(self.device))
1277
+ except Exception:
1278
+ return 1
1279
+
1280
+ def _update_overview(self) -> None:
1281
+ """Refresh the overview cards' live mini-stats from the (cheap) scheduler snapshot.
1282
+ Only the always-cheap cards carry a live number; Memory / File system / Builder keep a
1283
+ descriptive line so the hub never hammers the serial just to draw the front door."""
1284
+ cards = getattr(self, "_ov_cards", None)
1285
+ if not cards:
1286
+ return
1287
+ snap = self.state.latest
1288
+ procs = snap.procs if snap else []
1289
+ user = [p for p in procs if p.pid and p.pid > 2] # everything past init(1)+sh(2)
1290
+ active = [p for p in procs if p.state in ("running", "runnable", "sleeping")]
1291
+ rate = getattr(self, "_switch_rate", 0.0)
1292
+ ncpu = self._configured_cores() # the cores QEMU was launched with (Size), not the
1293
+ run = snap.running_pid if snap else None # idle live-sample (idle cores emit no dump lines)
1294
+ if "programs" in cards:
1295
+ cards["programs"].set_stat(
1296
+ f"{len(user)} user program{'s' if len(user) != 1 else ''}" if user
1297
+ else "shell ready")
1298
+ if "scheduler" in cards:
1299
+ cards["scheduler"].set_stat(f"{len(active)} procs · {rate:.1f} sw/s")
1300
+ if "journey" in cards:
1301
+ cards["journey"].set_stat(f"{self.state.timeline.switches()} switches so far")
1302
+ if "cpu" in cards:
1303
+ cards["cpu"].set_stat(
1304
+ f"{ncpu} core{'s' if ncpu != 1 else ''}"
1305
+ + (f" · running pid {run}" if run else " · idle"))
1306
+ # descriptive (no cheap live number without an extra serial read)
1307
+ cards.get("syscalls") and cards["syscalls"].set_stat("open for live histogram →")
1308
+ cards.get("builder") and cards["builder"].set_stat("generate kernel edits →")
1309
+ cards.get("memory") and cards["memory"].set_stat("page tables & faults →")
1310
+ cards.get("storage") and cards["storage"].set_stat("inodes & the log →")
1311
+
1312
+ def closeEvent(self, e) -> None: # noqa: N802
1313
+ self._closed = True # stop any in-flight worker from signalling a dead dialog
1314
+ self._poll.stop()
1315
+ self._ov_poll.stop()
1316
+ self._shadow_poll.stop()
1317
+ try:
1318
+ self.snap_ready.disconnect(self._on_snap)
1319
+ except (RuntimeError, TypeError):
1320
+ pass
1321
+ super().closeEvent(e)
1322
+
1323
+
1324
+ class Xv6Console(QDialog):
1325
+ """An in-app xv6 serial console. The agent owns the single QEMU serial (a second raw client
1326
+ would be refused), so this reads the console tail via /console and sends input via /input —
1327
+ all off the GUI thread."""
1328
+
1329
+ tail_ready = Signal(str)
1330
+
1331
+ def __init__(self, parent, theme: ThemeManager, provider, device=None) -> None:
1332
+ super().__init__(parent)
1333
+ self.theme = theme
1334
+ self.provider = provider
1335
+ t = theme.theme
1336
+ self.setWindowTitle(f"xv6 console — {getattr(device, 'name', 'xv6')}")
1337
+ self.resize(720, 460)
1338
+ self.setStyleSheet(f"QDialog{{background:{t.bg};}}")
1339
+ v = QVBoxLayout(self)
1340
+ self.view = QPlainTextEdit(); self.view.setReadOnly(True)
1341
+ self.view.setStyleSheet(
1342
+ f"QPlainTextEdit{{background:{t.panel};color:{t.text};border:1px solid {t.line};"
1343
+ "border-radius:6px;font-family:monospace;font-size:12px;}")
1344
+ v.addWidget(self.view, 1)
1345
+ row = QHBoxLayout()
1346
+ self.input = QLineEdit(); self.input.setPlaceholderText("type a command and press Enter…")
1347
+ self.input.setStyleSheet(
1348
+ f"QLineEdit{{background:{t.panel};color:{t.text};border:1px solid {t.line};"
1349
+ "border-radius:6px;padding:6px;font-family:monospace;}")
1350
+ self.input.returnPressed.connect(self._send)
1351
+ row.addWidget(self.input, 1)
1352
+ # xv6 has no `ps`; Ctrl-P dumps the process table. This button sends that control char
1353
+ # (awkward to type) and refreshes — a reliable manual process listing.
1354
+ pbtn = QPushButton(" List processes ⌃P")
1355
+ pbtn.setToolTip("Send Ctrl-P — xv6's process dump (its 'ps')")
1356
+ pbtn.setStyleSheet(
1357
+ f"QPushButton{{color:{t.text};background:{t.panel2};border:1px solid {t.line};"
1358
+ f"border-radius:6px;padding:6px 12px;}}QPushButton:hover{{border-color:{t.accent};}}")
1359
+ pbtn.clicked.connect(self._procdump)
1360
+ row.addWidget(pbtn)
1361
+ v.addLayout(row)
1362
+ hint = QLabel("xv6 has no `ps` — use ⌃P (above) to list processes.")
1363
+ hint.setStyleSheet(_scss(f"color:{t.faint};font-size:11px;"))
1364
+ v.addWidget(hint)
1365
+ self.tail_ready.connect(self._show)
1366
+ self._poll = QTimer(self); self._poll.timeout.connect(self._refresh)
1367
+ self._poll.start(1200)
1368
+ self._refresh()
1369
+
1370
+ def _bg(self, fn):
1371
+ import threading
1372
+ threading.Thread(target=fn, daemon=True).start()
1373
+
1374
+ def _procdump(self):
1375
+ # send Ctrl-P (xv6's process dump), then pull the console so it shows up
1376
+ import time
1377
+
1378
+ def work():
1379
+ try:
1380
+ self.provider.send_input("\x10")
1381
+ time.sleep(0.4)
1382
+ txt = self.provider.console()
1383
+ except Exception:
1384
+ txt = ""
1385
+ self.tail_ready.emit(txt or "")
1386
+ self._bg(work)
1387
+
1388
+ def _refresh(self):
1389
+ def work():
1390
+ try:
1391
+ txt = self.provider.console()
1392
+ except Exception:
1393
+ txt = ""
1394
+ self.tail_ready.emit(txt or "")
1395
+ self._bg(work)
1396
+
1397
+ def _show(self, txt):
1398
+ if txt and txt != self.view.toPlainText():
1399
+ sb = self.view.verticalScrollBar()
1400
+ at_bottom = sb.value() >= sb.maximum() - 4
1401
+ self.view.setPlainText(txt)
1402
+ if at_bottom:
1403
+ sb.setValue(sb.maximum())
1404
+
1405
+ def _send(self):
1406
+ line = self.input.text()
1407
+ self.input.clear()
1408
+ self._bg(lambda: self.provider.send_input(line + "\n"))
1409
+
1410
+ def closeEvent(self, e): # noqa: N802
1411
+ self._poll.stop()
1412
+ super().closeEvent(e)