Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
550 changes: 46 additions & 504 deletions README.md

Large diffs are not rendered by default.

3 changes: 3 additions & 0 deletions demo.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@

DEMO_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "demos")
if DEMO_DIR not in sys.path:
# demos/common.py is intentionally imported as a sibling by runnable demo
# files. Adding this directory preserves the original root command without
# duplicating the showcase implementation.
sys.path.insert(0, DEMO_DIR)


Expand Down
6 changes: 6 additions & 0 deletions demos/adapters_asyncio_demo.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ def _check_loop(self) -> int:
return loop_id

async def open(self) -> int:
# These resources are created on the adapter loop, never on the smallOS
# scheduler thread or a temporary asyncio.run() loop.
self.owner_loop_id = self._check_loop()
self.queue = asyncio.Queue()
self.worker_task = asyncio.create_task(self._run())
Expand Down Expand Up @@ -71,6 +73,8 @@ async def asyncio_example(
service: AsyncioWorker,
) -> tuple[str, str]:
"""Create and reuse a loop-affine service across adapter calls."""
# Separate calls reuse the adapter's one persistent loop, which is required
# by queues, futures, clients, and background tasks with loop affinity.
opened_loop = await adapter.call(service.open)
try:
first, first_loop = await adapter.call(service.process, "smallos")
Expand All @@ -89,6 +93,8 @@ def main() -> None:
runtime = build_runtime(Unix())
service = AsyncioWorker()

# The context manager stays open until all SmallOS work using the adapter is
# finished, then tears down the foreign loop deterministically.
with AsyncioAdapter(max_pending=8) as foreign_async:
target = SmallTask(
2,
Expand Down
6 changes: 6 additions & 0 deletions demos/adapters_demo.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ async def blocking_adapter_demo(
task: SmallTask[str],
adapter: ThreadAdapter,
) -> str:
# adapter.call emits a smallOS-owned instruction. The worker thread never
# mutates scheduler queues or resumes this task directly.
result = await adapter.call(blocking_library_call, "SmallOS")
task_runtime(task).print(result + "\n")
return result
Expand All @@ -37,6 +39,8 @@ async def asyncio_adapter_demo(
task: SmallTask[str],
adapter: AsyncioAdapter,
) -> str:
# The callable runs on the adapter's persistent asyncio loop, then its result
# returns through a readiness object watched by the smallOS kernel.
result = await adapter.call(asyncio_library_call, "SmallOS")
task_runtime(task).print(result + "\n")
return result
Expand All @@ -51,6 +55,8 @@ async def cooperative_peer(task: SmallTask[str]) -> str:

def main() -> None:
runtime = build_runtime(Unix())
# Adapter lifetime surrounds runtime.start(): shutdown is explicit, and a
# live adapter binds to the first SmallOS runtime that submits work to it.
with ThreadAdapter(max_workers=2, max_pending=8) as blocking:
with AsyncioAdapter(max_pending=8) as foreign_async:
runtime.fork(
Expand Down
8 changes: 7 additions & 1 deletion demos/adapters_sqlite_demo.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ def __init__(self) -> None:
self.owner_thread_id: int | None = None

def open(self) -> None:
# SQLite's default connection enforces same-thread use. Record the lane
# owner so this demo turns an ownership mistake into a clear exception.
self.owner_thread_id = threading.get_ident()
self.connection = sqlite3.connect(":memory:")
self.connection.execute(
Expand Down Expand Up @@ -60,6 +62,9 @@ async def sqlite_example(
store: SQLiteStore,
) -> list[tuple[int, str]]:
"""Create, use, and close SQLite entirely on the adapter worker."""
# Creation, every operation, and close all traverse the same one-worker lane.
# Creating the connection on the smallOS scheduler thread would violate the
# ownership contract as soon as the adapter tried to use it.
await adapter.call(store.open)
try:
await adapter.call(
Expand All @@ -77,7 +82,8 @@ def main() -> None:
runtime = build_runtime(Unix())
store = SQLiteStore()

# One worker creates a serialized execution lane for this connection.
# One worker creates a serialized execution lane for this connection;
# max_pending bounds queued work without blocking the scheduler.
with ThreadAdapter(max_workers=1, max_pending=8) as blocking:
target = SmallTask(
2,
Expand Down
12 changes: 12 additions & 0 deletions demos/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@


CONFIG_PATH = os.path.join(REPO_ROOT, "smallos.config.json")
# Signals are integer slots. A named constant gives application-level meaning
# to a slot without making that meaning part of the smallOS scheduler.
DEMO_SIGNAL = 3


Expand All @@ -38,6 +40,9 @@ def load_demo_config(**overrides):

def build_runtime(kernel: Kernel, **config_overrides: Any) -> SmallOS:
"""Create a ``SmallOS`` instance wired to the chosen kernel."""
# The kernel owns platform behavior; SmallOS owns task scheduling. Keeping
# this attachment explicit is what lets the same task code use Unix or a
# MicroPython board profile.
runtime = SmallOS(config=load_demo_config(**config_overrides)).setKernel(kernel)
return install_demo_error_handler(runtime)

Expand Down Expand Up @@ -93,6 +98,8 @@ def install_demo_error_handler(runtime, include_cancelled=False):
"""Attach the shared demo error logger to ``runtime``."""

def _handler(event):
# Error handlers are synchronous observers. They report a task after
# finalization and must not try to drive coroutine work themselves.
runtime.kernel.write(_format_failure_event(event))

runtime.setErrorHandler(_handler, include_cancelled=include_cancelled)
Expand All @@ -110,6 +117,8 @@ async def worker(task):
async def join_demo(task):
"""Show child spawning plus ordered ``join_all`` collection."""
task.OS.print("join demo starting\n")
# Smaller priority numbers are scheduled first. The join result below is
# nevertheless returned in this caller-supplied order.
fast = task.spawn(worker, priority=1, name="fast")
medium = task.spawn(worker, priority=3, name="medium")
slow = task.spawn(worker, priority=5, name="slow")
Expand All @@ -130,6 +139,7 @@ async def signal_demo(task):
"""Show a task blocked on a signal and then joined with its sender."""
task.OS.print("signal demo waiting\n")
sender = task.spawn(signal_sender, priority=max(1, task.priority - 1), name="signal_sender")
# wait_signal suspends this task; it does not block the scheduler thread.
signal = await task.wait_signal(DEMO_SIGNAL)
sender_result = await task.join(sender)
task.OS.print("signal demo resumed on {} with {}\n".format(signal, sender_result))
Expand All @@ -145,6 +155,8 @@ async def startup_banner(task, board_name):

def default_tasks(board_name):
"""Return a small starter task set used by most demos."""
# Each SmallTask wraps an async routine. fork() later assigns PIDs and puts
# these ready tasks into their per-priority FIFO queues.
return [
SmallTask(2, startup_banner, name="startup_banner", args=(board_name,)),
SmallTask(4, signal_demo, name="signal_demo"),
Expand Down
4 changes: 4 additions & 0 deletions demos/esp32_demo.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,11 @@ def maybe_connect_wifi(kernel):


def main():
# The profile centralizes board-specific Wi-Fi, timing, socket, and polling
# behavior so the tasks below do not import MicroPython modules directly.
kernel = ESP32(hostname=WIFI_HOSTNAME)
# Leave credentials as None when network access is not needed. Do not commit
# real secrets to a demo; inject them through your deployment workflow.
maybe_connect_wifi(kernel)

runtime = build_runtime(kernel)
Expand Down
4 changes: 4 additions & 0 deletions demos/http_demo.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,10 @@


async def http_demo(task):
# Passing the attached task lets the client inherit transport limits from
# task.OS.config and suspend on this runtime's kernel readiness operations.
client = SmallHTTPClient(task, base_url=HTTP_BASE_URL)
# While connect/send/receive waits for the socket, other smallOS tasks may run.
response = await client.get("/", headers={"Accept": "text/html"})
preview = response.text().replace("\n", " ")[:120]
task.OS.print("http status: {} {}\n".format(response.status_code, response.reason))
Expand All @@ -19,6 +22,7 @@ async def http_demo(task):

def main():
runtime = build_runtime(Unix())
# Priority 2 is a scheduler category; lower numeric categories run first.
runtime.fork([SmallTask(2, http_demo, name="http_demo")])
runtime.startOS()

Expand Down
3 changes: 3 additions & 0 deletions demos/micropython_autodetect_demo.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@ def maybe_connect_wifi(kernel):


def main():
# Detection reads the firmware machine string and returns a matching built-in
# profile. Explicit ESP32/PicoW construction is preferable when an app needs
# profile-specific settings.
kernel = build_micropython_kernel()
maybe_connect_wifi(kernel)

Expand Down
4 changes: 4 additions & 0 deletions demos/mqtt_demo.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@


async def mqtt_demo(task):
# Point these settings at a broker you control. QoS 1 is used so the demo
# visibly exercises acknowledgement handling rather than fire-and-forget.
client = SmallMQTTClient(
task,
host=MQTT_HOST,
Expand All @@ -21,6 +23,7 @@ async def mqtt_demo(task):
client_id="smallos-demo-client",
)
await client.connect()
# Every network await yields to smallOS while the broker socket is not ready.
suback = await client.subscribe(MQTT_TOPIC, qos=MQTT_QOS)
publish_info = await client.publish(MQTT_TOPIC, "hello from smallOS", qos=MQTT_QOS)
task.OS.print("mqtt subscribed to {} with granted QoS {}\n".format(MQTT_TOPIC, suback["granted_qos"]))
Expand All @@ -30,6 +33,7 @@ async def mqtt_demo(task):
task.OS.print(
"mqtt received {} -> {} at QoS {}\n".format(message["topic"], message["payload"], message["qos"])
)
# A clean MQTT disconnect is part of protocol cleanup, not just socket close.
await client.disconnect()
return message

Expand Down
3 changes: 3 additions & 0 deletions demos/pico_w_demo.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,11 +24,14 @@ def maybe_connect_wifi(kernel):


def main():
# Country and power-management settings are profile options because their
# implementation is firmware/board specific, not scheduler policy.
kernel = PicoW(
country=WIFI_COUNTRY,
hostname=WIFI_HOSTNAME,
power_management=WIFI_POWER_MANAGEMENT,
)
# Keep real credentials outside source control in an application deployment.
maybe_connect_wifi(kernel)

runtime = build_runtime(kernel)
Expand Down
3 changes: 3 additions & 0 deletions demos/redis_demo.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@


async def redis_demo(task):
# The native client speaks RESP over a cooperative SmallStream. It does not
# start an asyncio loop or a background thread.
client = SmallRedisClient(
task,
host=REDIS_HOST,
Expand All @@ -23,6 +25,7 @@ async def redis_demo(task):
value = await client.get("smallos:demo")
task.OS.print("redis ping: {}\n".format(pong))
task.OS.print("redis value: {}\n".format(value))
# This task created the connection, so it also owns deterministic cleanup.
client.close()
return value

Expand Down
17 changes: 17 additions & 0 deletions demos/runtime_demo.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,16 +19,22 @@ async def priority_worker(task):
"""Simple child workload used to show priority-aware interleaving."""
for step in range(3):
task.OS.print("[{}] step {}\n".format(task.name, step))
# sleep() records a monotonic wake deadline and lets another ready task
# run; it never calls blocking time.sleep().
await task.sleep(0.05)
return task.name


async def join_demo(task):
"""Spawn three workers and collect their results in a fixed order."""
task.OS.print("join demo starting\n")
# spawn() establishes the parent/child relationship and returns the child
# object, which can be passed directly to join() or join_all().
fast = task.spawn(priority_worker, priority=1, name="fast")
medium = task.spawn(priority_worker, priority=3, name="medium")
slow = task.spawn(priority_worker, priority=5, name="slow")
# Completion timing may differ, but join_all preserves this requested order.
# A child exception would instead be raised into this parent task.
results = await task.join_all([fast, medium, slow])
task.OS.print("join demo results: {}\n".format(results))
return results
Expand All @@ -48,6 +54,8 @@ async def http_request_task(task, base_url=HTTP_BASE_URL, path=HTTP_PATH):
async def http_request_demo(task):
"""Show a network request running while the parent keeps doing work."""
task.OS.print("http request demo starting\n")
# The child handles socket readiness while this parent continues independent
# cooperative work. No operating-system thread is created for the request.
request = task.spawn(
http_request_task,
priority=max(1, task.priority - 1),
Expand All @@ -59,6 +67,7 @@ async def http_request_demo(task):
task.OS.print("http request parent doing other work {}\n".format(step))
await task.sleep(0.05)

# If the HTTP child failed, join() would raise that exception here.
response = await task.join(request)
task.OS.print("http request status: {} {}\n".format(response["status"], response["reason"]))
task.OS.print("http request preview: {}\n".format(response["preview"]))
Expand All @@ -69,6 +78,8 @@ async def signal_sender(task):
"""Sleep for a while and then wake the parent by sending a signal."""
await task.sleep(0.1)
task.OS.print("sender raising signal 3\n")
# Signals are latched integer slots. Sending before the parent reaches its
# wait is safe because wait_signal() consumes an already-latched signal.
task.sendSignal(task.parent.pid, 3)
return "signal sent"

Expand All @@ -87,12 +98,18 @@ async def cooperative_demo(task):
"""Show a task voluntarily yielding without waiting on time or signals."""
for index in range(5):
task.OS.print("cooperative tick {}\n".format(index))
# yield_now() remains immediately runnable but gives the priority queues
# another scheduling opportunity.
await task.yield_now()
return "done"


def main():
# build_runtime installs the Unix kernel, repository config, and a default
# task-failure observer shared by all demos.
runtime = build_runtime(Unix())
# These are top-level peers. Lower numeric priorities are considered first;
# awaits still allow lower-priority work to make progress while peers wait.
runtime.fork(
[
SmallTask(2, http_request_demo, name="http_request_demo"),
Expand Down
8 changes: 8 additions & 0 deletions demos/shell_demo.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ async def shell_session(task):
still uses the same command parser and runtime APIs as an interactive shell
would.
"""
# The shell is already attached to this runtime. Commands below call the
# same APIs an interactive stdin-backed shell uses.
shell = task.OS.shells[0]
worker_pid = _pid_for_name(task.OS, "background_worker")
script = [
Expand All @@ -45,6 +47,8 @@ async def shell_session(task):
]

for command in script:
# Sleeping between commands proves background tasks continue to advance
# instead of a blocking input loop owning the process.
await task.sleep(0.05)
shell.run(command, show_prompt=False, echo_command=True, force_output=True)
if not shell.is_running:
Expand All @@ -53,9 +57,13 @@ async def shell_session(task):


def main():
# allow_python defaults to True for a local debugging shell. Disable it for
# any input source that is not fully trusted.
shell = BaseShell()
runtime = build_runtime(Unix())
runtime.shells.append(shell.setOS(runtime))
# The scripted session is a normal task, so its output and priority interact
# with application work through the same scheduler.
runtime.fork(
[
SmallTask(2, shell_session, name="shell_session"),
Expand Down
4 changes: 4 additions & 0 deletions demos/unix_demo.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,12 @@


def main():
# Unix supplies desktop timing, terminal output, sockets, and selector-based
# readiness while the runtime remains responsible for task ordering.
runtime = build_runtime(Unix())
# Registration assigns PIDs but does not execute user routines yet.
runtime.fork(default_tasks("Unix"))
# startOS() drives the scheduler until no non-watcher work remains.
runtime.startOS()


Expand Down
Loading
Loading