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
118 changes: 62 additions & 56 deletions README.md

Large diffs are not rendered by default.

425 changes: 323 additions & 102 deletions docs/code-index.md

Large diffs are not rendered by default.

18 changes: 13 additions & 5 deletions docs/private-accounts.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,11 @@ Import flat source/data files or start from the clearly synthetic CSV draft. Cho

Default roots:

| OS | Dedicated private root |
|---|---|
| Windows | `%LOCALAPPDATA%/CodeFlowStudioAccounts` |
| macOS | `~/Library/Application Support/CodeFlowStudioAccounts` |
| Linux | `$XDG_DATA_HOME/CodeFlowStudioAccounts` or `~/.local/share/CodeFlowStudioAccounts` |
| OS | Dedicated private root |
| ------- | ---------------------------------------------------------------------------------- |
| Windows | `%LOCALAPPDATA%/CodeFlowStudioAccounts` |
| macOS | `~/Library/Application Support/CodeFlowStudioAccounts` |
| Linux | `$XDG_DATA_HOME/CodeFlowStudioAccounts` or `~/.local/share/CodeFlowStudioAccounts` |

The native launcher has a separate `CodeFlowStudioLauncher` directory for nonsecret startup logs. The new account root never opens or migrates older capstone/native/developer history. A trusted operator can explicitly override `CFS_ACCOUNT_DATA` to an empty dedicated directory; this is not a browser-selectable path. A held OS advisory lock prevents two services sharing it.

Expand Down Expand Up @@ -61,6 +61,14 @@ python -m studio.private_cli

The printed URL is nonsecret. Ctrl+C requests sealing. Legacy `python -m studio.cli serve` intentionally remains a different bearer-token developer mode; do not expose either service to a network.

Normal native shutdown stops accepting requests and gives existing HTTP work five
seconds to drain. Remaining HTTP tasks are cancelled and their owned cleanup is
joined before vault sealing. Already-started local disk/account transitions finish
under the request mutex before cancellation is reported; arbitrary network calls
and request-body reads remain cancellable. Slow or failed critical I/O can still
delay completion: preserve recovery data and never force-delete an active account
directory. See the [actual cancellation and native acceptance report](reports/native-http-drain-2026-11-10.md).

Limits: 20 flat files/1 MiB project; 192 KiB browser file import; 64 cases; 4 MiB HTTP request; 500 revisions/128 MiB history; 512 MiB/2,000 archived workspace files; eight providers; one unlocked engine; bounded worker output/deadlines/resources. Unexpected/unsupported formats fail explicitly. Run hostile unknown code in a dedicated disposable VM with independent review; containers are not a certified hostile multi-tenant boundary.

The 128 MiB history threshold stops new revisions and run queues. A 160 MiB recovery reserve keeps existing history readable after a final write/in-flight result crosses the soft threshold. Reads, backups, lock/reopen and existing report exports remain available; no history is automatically erased to satisfy a quota.
Expand Down
79 changes: 79 additions & 0 deletions docs/reports/native-http-drain-2026-11-10.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
# Native HTTP drain and coherent local cancellation — 2026-11-10

By Jason Stys.

## Observed defect and scope

The [Windows native job](https://github.com/JasonStys/code-flow-studio/actions/runs/42796136482/job/129761676463)
at documentation PR head `7fc7693aba990925d35010fc0644108644e8eb3c` completed account,
editor, privacy and explicit workspace-lock checks. Its actual shutdown response
was HTTP 200 with `stopping: true`, and the frozen process logged the shutdown
callback. It did not reach the lifespan-close milestone before the unchanged
30-second launcher wait. Acknowledgement was not proof of service termination.

The pinned Uvicorn 0.38 server was using its default unlimited graceful HTTP drain.
Real owned-loopback experiments reproduced that pre-lifespan wait with both a held
handler and an incomplete POST body. A finite drain cancels those requests and
allows normal vault sealing. These controls establish the mechanism; they do not
identify which request remained open in the historical Windows failure.

## Correction

The account entry point now uses a five-second HTTP-drain budget. `NativeServer`
retains Uvicorn's listener, connection and normal lifespan handling. At drain
expiry it cancels and joins its own remaining HTTP tasks before lifespan shutdown.
There is no `force_exit`, process termination, skipped sealing, deadline increase
or test retry.

Raw `asyncio.Task.cancel()` can otherwise release an awaiting request's mutex while
Starlette/AnyIO thread work continues. Retained shielded operations now finish real
disk work before propagating cancellation, including repeated cancellation.
Unlock and lock complete their full marker/controller/key and seal/remove transitions.
Provider publication completes disk settings, actual in-memory destinations/keys
and snapshot together; authorized deletion, logout and recovery reset also finish
their local transitions under the existing request mutex. Network requests, AI
calls and request-body reads are not made uncancellable.

The HTTP budget is not a universal process-exit deadline: critical disk work,
owned-container cleanup and authenticated sealing must finish safely. A stalled
disk or unavailable cleanup can still prevent clean termination; recovery data
must be preserved rather than forcibly deleted.

## Verification

The real HTTP tests create and authenticate fictional accounts, retain an actual
revision, and hold a handler, partial body, real snapshot or authenticated restore.
At genuine cancellation they verify the request mutex remains held until offloaded
work completes. Normal lifespan then seals the workspace, removes plaintext and
the owner marker, clears keys, and restores the exact revision from its real
AES-GCM archive. Separate repeated-cancel/success/error controls check actual
thread completion. Real provider/deletion tests check consent/destination and
data/account-row coherence after cancellation.

Local Windows validation used Python 3.14.6, pinned Uvicorn 0.38, Electron 44.10.2
and Playwright 1.64.1. The full controller/training gate, original coverage floor,
strict TypeScript build, three-engine controller/account journeys and native
acceptance remain required. Results and counts are recorded in the integration
handoff rather than inferred from source inspection.

A freshly frozen service passed the actual Electron SDK journey. The freshly
compiled unpacked Windows application passed the unchanged account/editor/privacy/
lock journey in 6.2 seconds. It reported six connections and zero tasks before
normal drain, then zero of each afterward, completed workspace/lifespan/server/
main milestones, and both owned Python-service and bootloader PIDs had exited.
This is local dirty-build evidence, not a published release or clean-machine
installation claim. Exact-source hosted checks on all three operating systems,
full-main CodeQL and later fresh draft/download acceptance remain separate gates.

## References and retained boundaries

- [Pinned Uvicorn shutdown implementation](https://github.com/encode/uvicorn/blob/0.38.0/uvicorn/server.py)
drains connections/tasks before lifespan shutdown.
- [Python cancellation and shielding](https://docs.python.org/3.14/library/asyncio-task.html#shielding-from-cancellation)
distinguishes a protected child from its still-cancelled caller and requires
retaining a strong task reference.
- [Private account operation](../private-accounts.md) describes local trust,
recovery and the required container-only compiler boundary.

No user account, installed application, existing public release, Docker engine,
source-consent rule or worker-isolation permission was changed by these tests.
74 changes: 74 additions & 0 deletions studio/completed_offload.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
# <code-map>
# Generated definition map; @L is the exact declaration line.
# Function/class docs explain behavior; variables/parameters are scoped inputs, outputs or state.
# functions: completed_offload(function)@L24; completed_operation(operation)@L38; capture()@L45
# variables: work@L52; cancelled@L53; result@L56; succeeded@L56; cancelled@L63
# parameters: args@L24; function@L24; kwargs@L24; operation@L38
# </code-map>

"""Jason Stys: complete owned blocking work before propagating HTTP-task cancellation.

Symbol locations: docs/code-index.md. Never release a surrounding vault/request
mutex while its worker can still read or replace the protected filesystem state.
"""

import asyncio
from collections.abc import Awaitable, Callable
from typing import cast

from starlette.concurrency import run_in_threadpool

from studio.native_lifecycle import native_stage


async def completed_offload[**P, T](function: Callable[P, T], *args: P.args, **kwargs: P.kwargs) -> T:
"""Defer even repeated outer Task.cancel until the real owned thread operation finishes.

Starlette/AnyIO's default cancel scope does not protect against raw asyncio
Task.cancel. A separately retained shielded task keeps the caller inside its
existing mutex. Worker errors are transported as data to avoid Python 3.14
shield logging a child failure after outer cancellation; the sole caller
then receives the actual error or the original cancellation with its cause.
This helper never cancels its child or uses a timeout to abandon disk work.
"""

return await completed_operation(run_in_threadpool(function, *args, **kwargs))


async def completed_operation[T](operation: Awaitable[T]) -> T:
"""Finish one bounded owned state transition before propagating outer cancellation.

Use only for disk work or coherent workspace transitions, never an unbounded
network request/body. The surrounding caller retains its existing mutex.
"""

async def capture() -> tuple[bool, T | Exception]:
"""Return ordinary errors as data for the sole awaiting task, without shield child-error logging."""
try:
return True, await operation
except Exception as error:
return False, error

work = asyncio.create_task(capture())
cancelled = None
while True:
try:
succeeded, result = await asyncio.shield(work)
break
except asyncio.CancelledError as error:
if work.cancelled():
raise RuntimeError("Owned offload was independently cancelled") from error
if cancelled is None:
native_stage("offload-cancellation-deferred")
cancelled = cancelled or error
except BaseException as error:
if cancelled is not None:
raise cancelled from error
raise
if cancelled is not None:
if not succeeded:
raise cancelled from cast(Exception, result)
raise cancelled
if not succeeded:
raise cast(Exception, result)
return cast(T, result)
13 changes: 9 additions & 4 deletions studio/native_lifecycle.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
# <code-map>
# Generated definition map; @L is the exact declaration line.
# Function/class docs explain behavior; variables/parameters are scoped inputs, outputs or state.
# functions: native_stage(stage)@L17
# parameters: stage@L17
# functions: native_stage(stage, connections, tasks)@L18
# variables: counts@L21; count@L26; name@L26
# parameters: connections@L18; stage@L18; tasks@L18
# </code-map>

"""Jason Stys: opt-in native-test lifecycle milestones containing no account or source values.
Expand All @@ -14,11 +15,15 @@
import os


def native_stage(stage: str) -> None:
def native_stage(stage: str, *, connections: int | None = None, tasks: int | None = None) -> None:
"""Expose a fixed caller-supplied milestone and owned process identities only in native test mode."""
if os.environ.get("STUDIO_DESKTOP_TEST") == "1":
counts = {"connections": connections, "tasks": tasks}
print(
"NATIVE_SERVICE_STAGE",
json.dumps({"stage": stage, "pid": os.getpid(), "parent_pid": os.getppid()}),
json.dumps({
"stage": stage, "pid": os.getpid(), "parent_pid": os.getppid(),
**{name: count for name, count in counts.items() if type(count) is int and count >= 0},
}),
flush=True,
)
69 changes: 69 additions & 0 deletions studio/native_server.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
# <code-map>
# Generated definition map; @L is the exact declaration line.
# Function/class docs explain behavior; variables/parameters are scoped inputs, outputs or state.
# classes: NativeServer@L34
# functions: native_config(app, port)@L25; shutdown(self, sockets)@L37; stage(self, name)@L43; _wait_tasks_to_complete(self)@L51
# variables: HTTP_DRAIN_SECONDS@L22; pending@L63; task@L63; task@L64
# parameters: app@L25; port@L25; self@L37; sockets@L37; name@L43; self@L43; self@L51
# </code-map>

"""Jason Stys: finite HTTP drain followed by completed request cleanup and normal vault sealing.

Symbol locations: docs/code-index.md. Pinned Uvicorn owns sockets and lifespan;
this subclass only joins its own canceled HTTP tasks before the lifespan can close.
"""

import asyncio

import uvicorn

from studio.native_lifecycle import native_stage

HTTP_DRAIN_SECONDS = 5.0


def native_config(app, port: int) -> uvicorn.Config:
"""Use identical literal-loopback and bounded-concurrency configuration in the shipping app and real tests."""
return uvicorn.Config(
app, host="127.0.0.1", port=port, access_log=False, log_level="warning",
proxy_headers=False, limit_concurrency=50, timeout_keep_alive=5,
timeout_graceful_shutdown=HTTP_DRAIN_SECONDS,
)


class NativeServer(uvicorn.Server):
"""Bound idle/held HTTP work without abandoning active account filesystem operations."""

async def shutdown(self, sockets=None):
"""Keep Uvicorn's normal listener/connection/lifespan cleanup, with value-free stage counts."""
self.stage("http-drain-started")
await super().shutdown(sockets=sockets)
self.stage("http-drain-complete")

def stage(self, name):
"""Observe counts only; no request routes, account data, source or authentication values."""
native_stage(
name,
connections=len(self.server_state.connections),
tasks=len(self.server_state.tasks),
)

async def _wait_tasks_to_complete(self):
"""On the configured drain timeout, finish cancellation cleanup before normal lifespan sealing.

Uvicorn 0.38 otherwise cancels HTTP tasks and immediately starts lifespan
shutdown. Joining those exact tasks lets protected offloads finish while
their request locks are still held. Disk operations retain data safety over
a fabricated fixed process-exit guarantee; no task is killed or detached.
"""
try:
await super()._wait_tasks_to_complete()
except asyncio.CancelledError:
self.stage("http-drain-budget-exhausted")
pending = tuple(task for task in self.server_state.tasks if not task.done())
for task in pending:
task.cancel("Native HTTP drain deadline reached; complete owned cleanup")
if pending:
await asyncio.gather(*pending, return_exceptions=True)
self.stage("http-request-cleanup-complete")
raise
Loading
Loading