Skip to content

Commit 02be287

Browse files
committed
resume 자원 카탈로그와 머신 데모 연결
1 parent 8bea0d1 commit 02be287

11 files changed

Lines changed: 147 additions & 17 deletions

File tree

README.ko.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -132,7 +132,7 @@ console.log(rt.run("len(values)")); // 3
132132
| 커널 내 ASGI (`AsgiServer` - dartlab 프로덕션 사용 중) | Beta |
133133
| uv 레인 (`bootEnv` / `freeze` / `runScript`), wheel 캐시, 터미널, syscall 브리지 | Beta |
134134
| 세션 부활 + `.pymachine` 이미지, 머신 저널(WAL) | Experimental |
135-
| 라이브 프로세스 fork, 장치 FS, init / cron, 가상 오리진 URL | Experimental |
135+
| 라이브 프로세스 fork, 장치 FS, init / cron / resume hook, 가상 오리진 URL | Experimental |
136136
| 아웃바운드 Python 소켓 (`SocketBridge`), 공유 커널 | Experimental |
137137
| non-Pyodide CPython 3.14 (`bootWasi` / `WasiSession`) | Research preview |
138138

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -132,7 +132,7 @@ Honest maturity by browser-gate coverage. Everything below has a runtime gate; t
132132
| In-kernel ASGI (`AsgiServer` - in dartlab production today) | Beta |
133133
| uv lane (`bootEnv` / `freeze` / `runScript`), wheel cache, terminal, syscall bridge | Beta |
134134
| Session revival + `.pymachine` images, machine journal (WAL) | Experimental |
135-
| Live process fork, device FS, init / cron, virtual-origin URL | Experimental |
135+
| Live process fork, device FS, init / cron / resume hooks, virtual-origin URL | Experimental |
136136
| Outbound Python sockets (`SocketBridge`), shared kernel | Experimental |
137137
| non-Pyodide CPython 3.14 (`bootWasi` / `WasiSession`) | Research preview |
138138

docs/README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ pyproc의 공개 지속 문서. "무엇을 지향하는가"(product)와 "어떻
1919
| | [release.md](operations/release.md) | 버전·태그·릴리즈 절차(`0.0.x` 라인, SHA 핀 소비) |
2020
| | [demoHosting.md](operations/demoHosting.md) | 라이브 데모 배포 절차(COOP/COEP 정적 호스팅, 루트 `_headers`) |
2121
| [consuming/](consuming/) | [contract.md](consuming/contract.md) | 소비 계약: 공개 표면, SHA 핀, 소비자별 배선 상태, Pyodide 버전 정합 |
22+
| | [resumeCatalog.md](consuming/resumeCatalog.md) | 부활 후 `resume.py`가 제품별로 다시 열어야 하는 fd/socket/DB connection 정책 |
2223

2324
## 빠른 라우팅 (영역 -> 문서)
2425

@@ -28,4 +29,5 @@ pyproc의 공개 지속 문서. "무엇을 지향하는가"(product)와 "어떻
2829
- 커밋 전 무엇이 green이어야 하나 -> [operations/testing.md](operations/testing.md)
2930
- 버전을 언제 어떻게 올리나 -> [operations/release.md](operations/release.md)
3031
- 제품에서 pyproc을 가져다 쓰는 법 -> [consuming/contract.md](consuming/contract.md)
32+
- 부활 후 제품 자원 재개설 정책 -> [consuming/resumeCatalog.md](consuming/resumeCatalog.md)
3133
- 외부 기여 -> [CONTRIBUTING.md](../CONTRIBUTING.md)

docs/consuming/contract.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -121,7 +121,7 @@ SRI 속성을 직접 걸 수 없으므로, 이 검증은 spawn 전 preflight다.
121121

122122
**가상 오리진 경계 (정직한 벽)**: SW 합성 응답이라 진짜 오리진과 다르다. `tests/attempts/runtimeParity/virtualOriginBoundaryProbe.html`이 이 경계를 브라우저에서 계속 실측한다. (1) `Set-Cookie`는 응답 header로 노출되지 않고 저장되지 않는다. 쿠키 세션에 의존하지 말고 `Authorization` header, bearer token, signed URL 같은 명시 토큰을 쓴다. (2) WebSocket upgrade는 Service Worker fetch 이벤트가 가로채지 않으므로 ASGI dispatch로 들어오지 않는다. 양방향 스트림은 별도 relay나 SocketBridge 계열로 설계한다. (3) 스트리밍/SSE는 `AsgiServer``http.response.body` 조각을 축적한 뒤 일괄 `Response`로 돌려준다. 청크 단위 UI 갱신이 필요한 제품은 이 경로에 의존하지 않는다. (4) 엔드포인트는 `async def` 강제(동기 dispatch 없음).
123123

124-
부활(저널/세션/openMachine) 후에는 파일 핸들·DB 커넥션 같은 프로세스 자원이 힙 델타만으로 보장되지 않는다: `.pymachine`은 파이썬 힙과 `/home/web` 파일 바이트를 복원하지만 열린 fd, 소켓, DB 커넥션은 다시 열어야 하므로, 소비자는 `Init.resume(reason)`으로 `/home/web/resume.py`를 실행해 그런 자원을 재개설한다. signature는 출처 검증이지 sandbox 권한 허가가 아니므로, 제품은 공개키 배포와 권한 UI를 별도로 관리한다.
124+
부활(저널/세션/openMachine) 후에는 파일 핸들·DB 커넥션 같은 프로세스 자원이 힙 델타만으로 보장되지 않는다: `.pymachine`은 파이썬 힙과 `/home/web` 파일 바이트를 복원하지만 열린 fd, 소켓, DB 커넥션은 다시 열어야 하므로, 소비자는 `Init.resume(reason)`으로 `/home/web/resume.py`를 실행해 그런 자원을 재개설한다. 제품별 정책은 [resumeCatalog.md](resumeCatalog.md)가 정본이다. signature는 출처 검증이지 sandbox 권한 허가가 아니므로, 제품은 공개키 배포와 권한 UI를 별도로 관리한다.
125125

126126
subpath export: `pyproc/assets`, `pyproc/runtime`, `pyproc/reactive`, `pyproc/syscall-bridge`, `pyproc/process-os`, `pyproc/worker`. **src 내부 경로 deep import 금지** (내부 파일 배치는 릴리즈 간 바뀔 수 있다. 실제로 v0.0.3에서 레이어 폴더로 재배치됐고 subpath 이름은 불변이었다).
127127

docs/consuming/resumeCatalog.md

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
# resume.py 자원 정책 카탈로그
2+
3+
`Session.load`, `MachineJournal.recover`, `openMachine`은 파이썬 힙과 `/home/web` 파일 바이트를 되살린다. 그러나 열린 파일 핸들, SQLite connection, WebSocket/relay connection, 브라우저 장치 권한, DOM callback 같은 프로세스 바깥 자원은 힙 델타만으로 보장하지 않는다. 이 문서는 제품이 `/home/web/resume.py`에 무엇을 넣어야 하는지의 카탈로그다.
4+
5+
## 공통 계약
6+
7+
- 기본 위치는 `/home/web/resume.py`다. 다른 경로가 필요하면 `rt.enableInit({ resumePath })`로 명시한다.
8+
- 부활 뒤 소비자가 `rt.enableInit().resume(reason)`을 호출한다. `reason``resume.py` 안에서 전역 `pyprocResumeReason`으로 읽는다.
9+
- 같은 파일은 여러 번 실행될 수 있으므로 idempotent여야 한다. 테이블 생성, 디렉터리 생성, 캐시 재구성은 `if not exists`와 재시도 가능 구조로 쓴다.
10+
- 다시 열 대상은 "힙에 객체가 남아 보여도 플랫폼 상태가 사라질 수 있는 것"이다: SQLite connection, 열린 파일 핸들, SocketBridge/relay 세션, ASGI 앱 전역 DB connection, 브라우저 장치 handle, 외부 권한 토큰의 메모리 캐시.
11+
- `/home/web`에 영속된 파일과 명시 설정을 정본으로 삼고, stale Python object는 신뢰하지 않는다.
12+
- 권한 요청은 제품 UI가 소유한다. `resume.py`가 카메라, 네트워크 relay, clipboard 같은 권한을 조용히 다시 열면 안 된다.
13+
- 장시간 package install이나 네트워크 fetch를 `resume.py`에 넣지 않는다. 부활 경로는 빠르게 수렴해야 한다.
14+
15+
권장 reason 값:
16+
17+
| reason | 언제 |
18+
|---|---|
19+
| `fresh.boot` | 제품이 첫 부팅에서도 같은 hook으로 자원을 여는 경우 |
20+
| `session.load` | `Session.load(dir, name)`|
21+
| `journal.recover` | `MachineJournal.recover()`|
22+
| `openMachine` | `openMachine(blob, trustOptions)`|
23+
| `kernel.failover` | 탭 리더 교체 뒤 follower가 저널에서 되살아난 경우 |
24+
25+
## 현재 고정된 표면
26+
27+
| 표면 | 상태 | resume.py 정책 | 검증 |
28+
|---|---|---|---|
29+
| `tests/attempts/pythonMachine/resumeHookProbe.html` | 계약 probe | sqlite connection을 `resumeConn`으로 다시 열고 reason/value를 기록한다. `Session.load`, `MachineJournal.recover`, `openMachine` 세 경로와 파일 없음 no-op을 검증한다 | `node tests/browser/run.mjs tests/attempts/pythonMachine/resumeHookProbe.html` |
30+
| `examples/machine.html` | 실제 데모 표면 | 첫 부팅 또는 부활 뒤 `/home/web/resume.py``appDb` SQLite connection을 열고 `resumeEvent`에 reason을 남긴다. signed `.pymachine` cast 후 `openMachine`에서도 같은 hook을 실행한다 | `npm run test:examples`, 또는 `node tests/browser/run.mjs examples/machine.html?gate=1` |
31+
32+
## 제품별 적용 정책
33+
34+
| 제품 | 재개설 대상 | resume.py에 둬야 할 것 | pyproc 쪽 판정 |
35+
|---|---|---|---|
36+
| codaro | 셀 실행 기록, `/home/web/codaro` 산출물 index, ASGI 개발 서버가 잡는 DB/file connection | `/home/web/codaro` 파일 트리를 정본으로 삼고, SQLite/index connection과 ASGI app 전역 connection을 다시 연다. 셀별 PyProxy, DOM handle, editor callback은 저장하지 않는다 | 다음 제품 소비 축에서 `.pymachine` 또는 `VirtualOrigin` 채택 시 gate로 고정 |
37+
| dartlab | notebook worker의 ASGI `/pyapi`, sqlite/파일 connection, 패키지 캐시 index | 자체 부팅 Pyodide를 `Runtime`으로 채택한 뒤, DB connection과 app state adapter를 `/home/web` 기준으로 다시 연결한다. FastAPI route 함수 자체보다 외부 connection 재개설이 핵심이다 | pyproc 계약은 준비됨. dartlab 채택 시 제품 gate 필요 |
38+
| xlpod | 스프레드시트 UDF 캐시, formula bridge callback, 취소 SAB, workbook별 산출물 | workbook 식별자와 `/home/web/xlpod` 산출물을 정본으로 삼고, callback/SAB/worksheet bridge는 힙에서 재사용하지 않고 호스트에서 다시 주입한다 | UDF 동기 브리지 채택 시 별도 gate 필요 |
39+
| 일반 외부 제품 | 사용자 파일, 로컬 DB, relay/session token, device permission | `/home/web/<app>` 아래 manifest를 두고 resume.py는 그 manifest만 읽어 connection을 다시 만든다. 권한이 필요한 자원은 먼저 UI 승인 상태를 확인한다 | 소비 계약으로 공개. 제품별 증거는 각 제품 gate에서 요구 |
40+
41+
## 최소 템플릿
42+
43+
```python
44+
import os, sqlite3
45+
46+
os.makedirs("/home/web/myApp", exist_ok=True)
47+
resumeReasonSeen = pyprocResumeReason
48+
appDbPath = "/home/web/myApp/app.db"
49+
appDb = sqlite3.connect(appDbPath)
50+
appDb.execute("create table if not exists resumeEvent(reason text)")
51+
appDb.execute("insert into resumeEvent(reason) values (?)", (resumeReasonSeen,))
52+
appDb.commit()
53+
```
54+
55+
제품은 이 템플릿을 그대로 복사하지 말고, 자기 자원 목록을 기준으로 얇게 유지한다. 핵심은 "힙에 남은 객체를 믿지 않고 `/home/web`의 파일과 명시 권한으로 다시 연다"는 점이다.

docs/operations/testing.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,7 @@ PYPROC_INDEX_URL=/vendor/pyodide/ npm run test:examples # 자가 호스팅
6767
생긴 이유(2026-07-12): 라이브러리 게이트는 라이브러리만 검증해서, 예제 코드의 실결함
6868
(BigInt 직렬화)이 라이브 데모까지 나갔다. 데모는 공개 진열장이므로 이 게이트가 회귀를 막는다.
6969
`PYPROC_INDEX_URL`을 주면 런타임 게이트와 같은 방식으로 예제 전체가 자가 호스팅 엔진 배포판을 쓴다.
70-
`machine.html`의 gate는 코드 실행뿐 아니라 signed `.pymachine` cast, trusted public key open, `/home/web` 복원을 함께 검증한다.
70+
`machine.html`의 gate는 코드 실행뿐 아니라 signed `.pymachine` cast, trusted public key open, `/home/web` 복원, `resume.py`의 SQLite connection 재개설을 함께 검증한다.
7171
`speedLab.html`의 gate는 단일 worker numpy matmul과 4-worker sharded matmul 결과가 일치하고 같은 run에서 speedup이 1을 넘는지 검증한다.
7272

7373
## 5. 수동 실측 (examples/)

examples/machine.html

Lines changed: 39 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ <h1>A computer that never turns off</h1>
3636
<div class="row" style="margin:.6rem 0 .2rem">
3737
<span class="badge ok">state survives the tab</span>
3838
<span class="badge info">/home/web is a real disk (OPFS)</span>
39+
<span class="badge">resume.py reopens SQLite</span>
3940
<span class="badge">WebCrypto signature on export</span>
4041
<span class="badge">trusted public-key import</span>
4142
</div>
@@ -45,8 +46,11 @@ <h1>A computer that never turns off</h1>
4546
<div class="panel">
4647
<p class="dim" style="margin:0 0 .5rem">Try it: run this, then close the tab and come back. The counter and the file keep counting.</p>
4748
<textarea id="code" rows="4" spellcheck="false">n = globals().get('n', 0) + 1
49+
appDb.execute('insert into resumeEvent(reason, n) values (?, ?)', ('run', n))
50+
appDb.commit()
51+
resumeEvents = appDb.execute('select count(*) from resumeEvent').fetchone()[0]
4852
open('/home/web/visits.txt', 'a').write(f'visit {n}\n')
49-
print('counter:', n, '| lines in visits.txt:', len(open('/home/web/visits.txt').readlines()))</textarea>
53+
print('counter:', n, '| lines in visits.txt:', len(open('/home/web/visits.txt').readlines()), '| resume events:', resumeEvents)</textarea>
5054
<div class="row">
5155
<button id="run">Run</button>
5256
<button id="sleep" class="ghost">Sleep now (manual save)</button>
@@ -69,6 +73,18 @@ <h1>A computer that never turns off</h1>
6973
const gateMode = params.has("gate");
7074
const INDEX = params.get("indexURL") ? new URL(params.get("indexURL"), location.href).href : undefined;
7175
const gateReport = (ok) => { if (!gateMode) return; fetch("/gateReport", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ ok: !!ok, checks: [{ name: document.title, pass: !!ok, info: (st.textContent + " | " + out.textContent).slice(-180) }] }) }).catch(() => {}); };
76+
const q = (value) => JSON.stringify(value);
77+
const resumeSrc = [
78+
"import os, sqlite3",
79+
"os.makedirs('/home/web', exist_ok=True)",
80+
"resumeReasonSeen = pyprocResumeReason",
81+
"appDbPath = '/home/web/app.db'",
82+
"appDb = sqlite3.connect(appDbPath)",
83+
"appDb.execute('create table if not exists resumeEvent(reason text, n integer)')",
84+
"appDb.execute('insert into resumeEvent(reason, n) values (?, ?)', (resumeReasonSeen, globals().get('n', 0)))",
85+
"appDb.commit()",
86+
"resumeEvents = appDb.execute('select count(*) from resumeEvent').fetchone()[0]",
87+
].join("\n");
7288
const opfs = await navigator.storage.getDirectory();
7389
const stateDir = await opfs.getDirectoryHandle("pyMachineState", { create: true });
7490
const homeDir = await opfs.getDirectoryHandle("pyMachineHome", { create: true });
@@ -154,18 +170,36 @@ <h1>A computer that never turns off</h1>
154170

155171
let session = await bootSession({ indexURL: INDEX });
156172
let home = await session.rt.mountHome(homeDir);
173+
async function ensureResumeHook() {
174+
session.rt.setGlobal("_pmResumeSrc", resumeSrc);
175+
session.rt.run([
176+
"import os",
177+
"os.makedirs('/home/web', exist_ok=True)",
178+
"if not os.path.exists('/home/web/resume.py'):",
179+
" open('/home/web/resume.py', 'w').write(_pmResumeSrc)",
180+
].join("\n"));
181+
}
182+
function resumeMachine(reason, init = session.rt.enableInit()) {
183+
const r = init.resume(reason);
184+
if (r.resume) print(`resume.py: reopened appDb (${reason}, events=${session.rt.run("resumeEvents")}).`);
185+
return r;
186+
}
157187
// resume: 이전 hibernate가 있으면 그 자리에서 깨어난다
188+
let resumeReason = "fresh.boot";
158189
try {
159190
const r = await session.load(stateDir, "machine");
160191
st.textContent = `Awake: previous session revived (${r.pages} pages, ${r.mb}MB). This computer never turned off.`;
192+
resumeReason = "session.load";
161193
} catch (e) {
162194
st.textContent = "Fresh boot (first visit). Make some state, then close the tab and come back.";
163195
}
196+
await ensureResumeHook();
164197

165198
// OS의 init: /home/web/boot.py가 있으면 부팅마다 저절로 돈다(rc.local). cron.py는 60초 틱.
166199
const init = session.rt.enableInit();
167200
const ran = init.install();
168201
if (ran.boot) print("init: ran /home/web/boot.py");
202+
resumeMachine(resumeReason, init);
169203

170204
const runCode = async () => {
171205
const code = document.getElementById("code").value;
@@ -193,6 +227,8 @@ <h1>A computer that never turns off</h1>
193227
clearRuntimeDir(next.rt, "/home/web");
194228
session = next;
195229
home = await session.rt.mountHome(homeDir);
230+
await ensureResumeHook();
231+
resumeMachine("openMachine");
196232
st.textContent = `Switched to a signed machine verified by key ${trustedKeyFp}.`;
197233
return next;
198234
};
@@ -209,7 +245,8 @@ <h1>A computer that never turns off</h1>
209245
const blob = await castSigned();
210246
const stateOk = session.rt.run("n >= 1") === true;
211247
const fileOk = String(session.rt.run("open('/home/web/visits.txt').read()")).includes("visit");
212-
gateReport(r.includes("counter") && blob.size > 0 && stateOk && fileOk && st.textContent.includes("verified"));
248+
const resumeOk = session.rt.run("resumeReasonSeen == 'openMachine' and appDb.execute('select count(*) from resumeEvent').fetchone()[0] >= 3") === true;
249+
gateReport(r.includes("counter") && blob.size > 0 && stateOk && fileOk && resumeOk && st.textContent.includes("verified"));
213250
} catch (e) { print(String(e).slice(-160)); gateReport(false); }
214251
}
215252
// 수명주기: 탭이 사라질 때 자동 hibernate

mainPlan/browser-os-north-star/01-architecture.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ pyproc이 소유하는 것은 제품 UI가 아니라 OS API와 커널 프리미
2626
| IPC | `pipe`, `shm`, `lock`, `semaphore` | 다중 producer/consumer, 큰 스트림, 오류 전파 계약 |
2727
| 네트워크 | `AsgiServer`, `VirtualOrigin`, `SocketBridge`, `virtualOriginBoundaryProbe` | 릴레이 운영 모델, 외부 제품 VirtualOrigin 채택 |
2828
| 보호 | `MachineJail`, trust flag, CSP, `.pymachine` WebCrypto signature, 부트 자산 SRI v2(`pyodide.js` + fetch core + pyproc worker graph preflight), `registerPyProcServiceWorker`, SW `coreIntegrity`, 실행 자산 manifest | 공개키 배포, 권한 UI는 소비 제품 계약으로 분리, 힙 평문 비밀 경고 |
29-
| 부팅/영속 | `bootEnv`, `freeze`, `MachineJournal`, `MachineJournal.pack/prune/autoPack`, `Init.resume`, `KernelElection`, `SharedKernel` | SharedKernel과 hibernate/resume 결합, 제품별 resume.py 적용 표 |
29+
| 부팅/영속 | `bootEnv`, `freeze`, `MachineJournal`, `MachineJournal.pack/prune/autoPack`, `Init.resume`, `KernelElection`, `SharedKernel`, `resumeCatalog` | SharedKernel과 hibernate/resume 결합, 외부 제품 resume.py gate |
3030
| 개발 표면 | `Terminal`, `%pip`, `%undo`, signed machine cast demo, Server Dev demo, Speed Lab demo | 제품 소비 배선, completion/history polish, compatibility lab |
3131
| 성능 | WebGPU, CPU sharding, snapshot fork, Speed Lab public benchmark | WebVM/JupyterLite/marimo 대비 정면 벤치 |
3232

@@ -42,6 +42,6 @@ pyproc이 소유하는 것은 제품 UI가 아니라 OS API와 커널 프리미
4242

4343
1. OS 판정표 v2에서 현재 점수는 몇 점인가.
4444
2. 공개키 배포와 권한 UI를 소비 제품에 어떤 계약으로 맡길 것인가.
45-
3. 제품별 `resume.py`어떤 fd, socket, DB connection을 다시 여는가.
45+
3. codaro/dartlab/xlpod의 `resume.py` 정책을 어떤 제품 gate로 고정할 것인가.
4646
4. 외부 소비 제품은 VirtualOrigin을 어떤 URL namespace와 인증 방식으로 채택할 것인가.
4747
5. 외부 소비 제품이 OS API만으로 가져가야 하는 최소 묶음은 무엇인가.

0 commit comments

Comments
 (0)