Skip to content

Commit 036690e

Browse files
committed
수정: 저널 공유 저장소 유실 차단
Runtime과 directory별 coordinator가 commit, recover, pack, prune, delete를 정렬한다. 주소 cache는 존재를 재확인하는 hint가 되고 delete epoch가 모든 facade에 전파된다. 검증: npm test 3595/3595, test:types, test:contracts, test:browser 137/137
1 parent 7ba6ccc commit 036690e

9 files changed

Lines changed: 242 additions & 89 deletions

File tree

‎docs/operations/contractReality.md‎

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,6 @@ It is consulted whenever the engine version moves or a capability changes.
99

1010
| Item | Contract | Actual | Next action |
1111
|---|---|---|---|
12-
| Intermittent journal commit loss across controllers | A successful commit whose ID is installed as HEAD recovers the exact heap, and later pack or prune keeps every object reachable from it | The Edge release gate once installed the expected branch commit as HEAD but recovered without the just-written `jbr` value. Its following `pack()` then reported a missing blob. The same revision passed before and after that run, and the existing `stateKernel` probe has not made the failure deterministic. The failing path creates multiple `MachineJournal` controllers over one Runtime and directory, while those controllers share the reactive heap but keep separate address caches and operation guards | Turn the multi-controller cache and storage race into a deterministic negative gate, define one coordination invariant per Runtime and directory, then remove this row only after repeated Edge stress and the full release gate pass |
1312
| A replay worker keeps a second copy of its own heap | `N` interpreters cost `N` heaps | A replay-booted worker holds `cp0`, a full byte copy of its heap, so a pool of `N` costs `2N` heaps. Two consumers need it: harvest (which only needs the changed page numbers) and drift cleanup (which needs the original bytes). Harvest could run on page hashes at 8 bytes per page instead, but that lowers the fork verdict from byte equality to a 64-bit collision probability, and the reactive path accepting that trade does not make the fork path accept it. The word-wise comparison landed first and cut the time cost of the same scan to about a quarter, so what remains is purely the memory multiple | Decide whether the fork and reactive paths should share one verdict strength. That is a product decision about correctness posture, not a refactor, and it belongs to whoever opens it deliberately |
1413
| The boot snapshot is made on the main thread | Booting a pool does not block the page | `_makeSnapshot` boots a whole extra Pyodide on the main thread and copies the snapshot into a SharedArrayBuffer, so pool creation carries one main-thread long task plus a global engine load. Moving it into a dedicated worker would remove both, but the worker kernel header records a measured fact that forbids assuming it is equivalent: a main kernel and a worker kernel do not replay to the same bytes | Measure first: prove a snapshot made in a worker is byte-identical to one made on the main thread, in `tests/attempts/runtimeParity`. Only then move it |
1514
| The global patch window costs boot concurrency | Two machines boot independently | The default boot always carries a core trust anchor, so it always opens the core-asset cache window, and that window is a single tab-wide chain because it swaps `globalThis.fetch`. Two concurrent `boot()` calls therefore overlap only partially: the browser gate measures 1.59x the single-boot wall clock for two at once (2.0x would be fully serial, 1.0x fully parallel). The window is not removable as-is - it exists because two concurrent global swaps restore each other's patch - so the cost is a property of the fetch-interception design, not a defect to patch away | Either intercept core-asset fetches without a global swap (an engine-level loader hook), or accept the cost and keep it measured. The gate records the ratio on every run so a regression toward 2.0x is visible |

‎docs/reference/api.md‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -335,6 +335,12 @@ The WAL engine under `machine.history`'s durable verbs, constructed via
335335
success/failure (`PYPROC_JOURNAL_IO`); `cfg.autoPack` packs past a loose-blob threshold;
336336
`cfg.pruneAfterCommit` trims the checkpoint tree each commit.
337337

338+
All journal facades created from the same Runtime and directory-handle object form one coordination
339+
domain. Commit, recover, branch, adopt, pack, prune, and delete are ordered through that domain;
340+
page-address hints and storage-handle invalidation are shared. This closes races inside one Runtime.
341+
A raw journal spanning tabs still needs an elected owner; the durable machine provides that owner
342+
through its existing lock and fence contract.
343+
338344
A successful commit writes `journalMarker.json` only after HEAD is complete. If that committed
339345
marker remains while HEAD and PREV are both absent, `recover()` raises
340346
`PYPROC_JOURNAL_EVICTED` instead of impersonating a fresh machine. `delete()` removes the backing

‎index.d.ts‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -700,7 +700,7 @@ declare class Init {
700700
}
701701

702702
export interface JournalConfig {
703-
/** Directory the journal lives in (OPFS or similar). The caller supplies it. */
703+
/** Directory the journal lives in (OPFS or similar). Facades using the same Runtime and handle share one coordination domain. */
704704
dir: FileSystemDirectoryHandle;
705705
/** Controller whose cp0 is the replay boundary (the reactive from bootSession). Required for revival. */
706706
reactive: ReactiveController;
@@ -797,6 +797,8 @@ export interface JournalRecoverResult {
797797
* survives even when a hibernate hook fails.
798798
* The contract: a crash loses everything "since the last commit". That is boundary consistency,
799799
* not per-statement durability.
800+
* Every facade created from one Runtime and directory handle shares operation ordering, address
801+
* cache lifetime, and storage invalidation. A raw journal used across tabs still needs one owner.
800802
*/
801803
declare class MachineJournal {
802804
readonly commits: number;

‎mainPlan/recoverableAutomationComputer/README.md‎

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,12 @@ pyproc을 브라우저 탭 안의 Python 실행기에서, 여러 guest와 자동
5050
| 7 | v86 browser probe | cold/warm boot, heap, 첫 화면, 입력, network, screenshot 수치 | 제품 최소선 충족 시 provider 후보 계약, 미달 시 명시적 경계 문서 |
5151
| 8 | 제품 졸업 | 설치물 양쪽 언어 여정, 전체 Node/브라우저/type 게이트, 계약 현실표 정리 | attempts 제거, 현재 문서 갱신, 이 이니셔티브 폴더 삭제 |
5252

53+
## 현재 실행 위치
54+
55+
- 단계 0 완료: 결정적 RED를 거쳐 Runtime+directory coordination domain, 주소 hint 존재 대조,
56+
storage epoch 공유를 구현했다. 정식 Edge 게이트 137/137과 Node 게이트 3595/3595가 통과했다.
57+
- 단계 1 진행: Control Protocol의 wire fixture와 음성 시험부터 시작한다.
58+
5359
## 제품 최소선
5460

5561
- 첫 명령부터 오류까지 request ID와 space ID로 추적 가능하다.
@@ -62,6 +68,6 @@ pyproc을 브라우저 탭 안의 Python 실행기에서, 여러 guest와 자동
6268

6369
## 외부 제품 검증
6470

65-
기존 `xlpod`와 `codaro` 점검 기록은 provider 구현의 사용자 여정 입력으로 사용한다. pyproc에서 고쳐야
66-
하는 결함은 이 이니셔티브에서 구현하고, 각 제품이 소유한 GUI와 학습 흐름 개선은 해당 저장소의
67-
계획 문서에만 남긴다. 다른 저장소를 pyproc 구현의 우회 경로로 수정하지 않는다.
71+
기존 외부 제품 점검 기록은 provider 구현의 사용자 여정 입력으로 사용한다. pyproc에서 고쳐야 하는
72+
결함은 이 이니셔티브에서 구현하고, 각 제품이 소유한 GUI와 학습 흐름 개선은 해당 저장소의 계획
73+
문서에만 남긴다. 다른 저장소를 pyproc 구현의 우회 경로로 수정하지 않는다.
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
// journalCoordinator.js - Layer 2: Runtime과 저널 디렉터리 하나의 연산 순서와 캐시 수명.
2+
//
3+
// Reactive heap은 Runtime당 하나다. 같은 Runtime과 directory handle을 쓰는 여러 journal
4+
// facade가 독립 lock과 주소 cache를 가지면 한쪽 pack이 지운 주소를 다른 쪽 commit이 다시
5+
// 단언하거나, recover가 commit의 lazy page 복사 중 heap을 바꿀 수 있다. 이 coordinator가
6+
// heap, ref, CAS를 만지는 연산을 한 줄로 세우고 주소 cache와 storage epoch를 함께 소유한다.
7+
8+
import { PyProcError } from "../../runtime/errors.js";
9+
10+
const RUNTIME_COORDINATORS = new WeakMap();
11+
12+
class JournalCoordinator {
13+
constructor(reactive) {
14+
this.reactive = reactive || null;
15+
this.addressCache = new Map();
16+
this.storageEpoch = 0;
17+
this._tail = Promise.resolve();
18+
this._pending = 0;
19+
}
20+
21+
assertReactive(reactive) {
22+
if (!this.reactive) this.reactive = reactive || null;
23+
if (reactive && this.reactive !== reactive) {
24+
throw new PyProcError(
25+
"PYPROC_INPUT_INVALID",
26+
"journal: one Runtime and directory must share one ReactiveController",
27+
);
28+
}
29+
}
30+
31+
get busy() { return this._pending > 0; }
32+
33+
run(operation) {
34+
this._pending++;
35+
const result = this._tail.then(async () => {
36+
try { return await operation(); }
37+
finally { this._pending--; }
38+
});
39+
// 실패한 작업도 다음 작업의 줄을 끊지 않는다. 오류는 result를 받은 호출자에게만 전달한다.
40+
this._tail = result.then(() => undefined, () => undefined);
41+
return result;
42+
}
43+
44+
async settle() { await this._tail; }
45+
46+
invalidateAddresses() { this.addressCache.clear(); }
47+
48+
resetStorage() {
49+
this.addressCache.clear();
50+
this.storageEpoch++;
51+
return this.storageEpoch;
52+
}
53+
}
54+
55+
export function journalCoordinatorFor(rt, dir, reactive) {
56+
// cfg 검증은 MachineJournal.start가 소유한다. 불완전한 수동 구성은 공유할 key가 없으므로
57+
// 독립 coordinator를 받고, 실제 연산 전에 기존과 같은 입력 오류가 난다.
58+
if (!rt || !dir || (typeof dir !== "object" && typeof dir !== "function")) {
59+
return new JournalCoordinator(reactive);
60+
}
61+
let byDirectory = RUNTIME_COORDINATORS.get(rt);
62+
if (!byDirectory) {
63+
byDirectory = new WeakMap();
64+
RUNTIME_COORDINATORS.set(rt, byDirectory);
65+
}
66+
let coordinator = byDirectory.get(dir);
67+
if (!coordinator) {
68+
coordinator = new JournalCoordinator(reactive);
69+
byDirectory.set(dir, coordinator);
70+
} else {
71+
coordinator.assertReactive(reactive);
72+
}
73+
return coordinator;
74+
}

0 commit comments

Comments
 (0)