diff --git a/CHANGELOG.en.md b/CHANGELOG.en.md index fd0a490..be8fb54 100644 --- a/CHANGELOG.en.md +++ b/CHANGELOG.en.md @@ -20,7 +20,29 @@ Chinese is the primary changelog and this file is its maintained English mirror. explicitly unapproved for distribution without changing the XCFramework Release's RootFS exclusion policy. -## v0.4.0-abi.6 (planned Stage1 maintenance prerelease) +## v0.4.0-abi.7 (planned Stage1 maintenance prerelease) + +This is a compatibility maintenance release after `v0.4.0-abi.6`. It remains a +prerelease and is **not stable v0.4.0**. + +- The pinned iSH fork adds `RENAME_NOREPLACE` syscall semantics, mapping them to + `renameatx_np(RENAME_EXCL)` in the Darwin fakefs backend and `renameat2` on a + Linux host. An existing destination is never replaced, without a racy + check-then-move sequence. +- The public C API adds `ish_embed_rename_noreplace`. It executes the atomic + rename through the content-addressed guest supervisor selected at boot and + returns guest errno in a bounded decimal record; malformed protocol output or + helper failure fails closed. +- The Swift API adds `IshInstance.renameNoReplace(from:to:timeout:)` and + `IshFilesystemError`. An existing destination maps to `.destinationExists`. + While source temporarily links `v0.4.0-abi.6`, a weak fallback reports + unsupported instead of producing a missing symbol. +- The public C ABI version remains 1 and wire protocol remains v4; the new + function symbol is backward-compatible and additive. RootFS remains outside + the Release. This version does not implement a native Agent Loop or install + Codex CLI. + +## v0.4.0-abi.6 (published Stage1 maintenance prerelease) This is a compatibility maintenance release after `v0.4.0-abi.5`. It remains a prerelease and is **not stable v0.4.0**. diff --git a/CHANGELOG.md b/CHANGELOG.md index 4e37bb0..ef6f8db 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,7 +15,24 @@ `fs.tar.gz`,环境差异仍保留在外部证据中。CI 不上传制品,候选仍明确标记为未获分发 批准,不改变 XCFramework Release 的 RootFS 排除策略。 -## v0.4.0-abi.6(计划中的 Stage1 维护预发布) +## v0.4.0-abi.7(计划中的 Stage1 维护预发布) + +这是 `v0.4.0-abi.6` 之后的兼容性维护版本,仍是 prerelease,**不是稳定 +v0.4.0**。 + +- 固定的 iSH fork 新增 `RENAME_NOREPLACE` syscall 语义,并在 Darwin fakefs 后端使用 + `renameatx_np(RENAME_EXCL)`、Linux host 后端使用 `renameat2`,从底层保证目标存在时 + 不覆盖,不采用易竞争的“先检查、再移动”。 +- 公开 C API 新增 `ish_embed_rename_noreplace`。它通过启动时选定的内容寻址 guest + supervisor 执行原子重命名,以有界十进制记录返回 guest errno;协议损坏或 helper + 异常会 fail closed。 +- Swift API 新增 `IshInstance.renameNoReplace(from:to:timeout:)` 与 + `IshFilesystemError`。目标已存在映射为 `.destinationExists`;源码临时链接 + `v0.4.0-abi.6` 时通过 weak fallback 明确报告 unsupported,不会产生缺失符号。 +- 公开 C ABI 版本仍为 1,wire protocol 仍为 v4;新增函数符号是向后兼容扩展。 + RootFS 不进入 Release。本版本不实现原生 Agent Loop,也不会安装 Codex CLI。 + +## v0.4.0-abi.6(已发布的 Stage1 维护预发布) 这是 `v0.4.0-abi.5` 之后的兼容性维护版本,仍是 prerelease,**不是稳定 v0.4.0**。 diff --git a/README.en.md b/README.en.md index 42a7f44..d42ddea 100644 --- a/README.en.md +++ b/README.en.md @@ -15,17 +15,17 @@ and simulator slices are arm64. ## Current phase: native ABI transition -The default branch has published `v0.4.0-abi.5` and is preparing the compatible -maintenance prerelease `v0.4.0-abi.6`. Both belong to the Stage1 **native ABI +The default branch has published `v0.4.0-abi.6` and is preparing the compatible +maintenance prerelease `v0.4.0-abi.7`. Both belong to the Stage1 **native ABI transition**. Neither is stable `v0.4.0` or the complete v0.4 Swift API. Keep these four version surfaces distinct: -| Surface | Current `v0.4.0-abi.5` | Planned `v0.4.0-abi.6` | +| Surface | Current `v0.4.0-abi.6` | Planned `v0.4.0-abi.7` | | --- | --- | --- | -| Public C ABI | `ISH_EMBED_ABI_VERSION == 1`; compatible symbols are published | Still ABI 1, with no new public symbols | +| Public C ABI | `ISH_EMBED_ABI_VERSION == 1`; compatible symbols are published | Still ABI 1, with one backward-compatible atomic-rename function symbol | | Internal wire protocol | exact-match v4 between host and embedded supervisor | still v4; this is not the public C ABI version | -| `Package.swift` | pins the public `v0.4.0-abi.5` URL/checksum | the release transaction creates a manifest-only release commit pinned to the maintenance binary | -| Swift source | remains v0.3.3-ABI compatible and does not call retain/release | keeps the same Swift API and old-ABI usage | +| `Package.swift` | pins the public `v0.4.0-abi.6` URL/checksum | the release transaction creates a manifest-only release commit pinned to the maintenance binary | +| Swift source | remains v0.3.3-ABI compatible and does not call retain/release | adds a typed rename API with a weak fallback for old binaries | Stage1 native code adds session retain/release, a joinable kernel thread, soft-halt, exact wire v4, and complete session close. The existing Swift wrapper @@ -88,15 +88,15 @@ gives those narrow differences independent PRs, CI, and an exact gitlink, making PocketRoot builds and releases reproducible. We do not directly rewrite somebody else's local upstream repository; generally useful fixes can still be contributed to [iSH upstream](https://github.com/ish-app/ish), while the fork carries project -gates until upstream accepts and releases them. The current `v0.4.0-abi.6` +gates until upstream accepts and releases them. The current `v0.4.0-abi.7` source change includes neither RootFS content nor any prebuilt XCFramework/guest binary; binaries may be produced and published only by a later release transaction after its gates pass. ## Installation status -`v0.4.0-abi.5` is public and [`Package.swift`](Package.swift) currently pins it. -Until `v0.4.0-abi.6` is published, the manifest keeps pointing at that verified +`v0.4.0-abi.6` is public and [`Package.swift`](Package.swift) currently pins it. +Until `v0.4.0-abi.7` is published, the manifest keeps pointing at that verified asset instead of advertising a future 404 URL. Use Xcode's **File → Add Package Dependencies…** with: @@ -108,8 +108,9 @@ Select a version whose tag, `libIshKernel.xcframework.zip`, Corresponding Source and manifest URL/checksum all match. Consumer projects do not need Meson, Zig, or LLVM. -`v0.4.0-abi.6` completes Swift-marshalling and stdin-close reuse of the original -SPAWN deadline. It does not implement a native Agent Loop or +`v0.4.0-abi.7` adds guest-atomic rename without a shell or check-then-rename +race and maps an existing destination to a typed Swift error. It does not +implement a native Agent Loop or install Codex CLI in the app. Node.js/npm remain optional choices of the RootFS/guest package-management flow, not runtime requirements. @@ -131,6 +132,15 @@ let result = try instance.runOneshot( .init(argv: ["/bin/echo", "hello"], timeout: 10) ) print(String(decoding: result.stdoutData, as: UTF8.self)) + +do { + try instance.renameNoReplace( + from: "/workspace/draft.txt", + to: "/workspace/final.txt" + ) +} catch IshFilesystemError.destinationExists { + // The destination remains unchanged; ask the user for another name. +} ``` `IshSpawnOptions.timeout` applies to both `runOneshot` and streaming `spawn`. diff --git a/README.md b/README.md index 66fe827..cb4a040 100644 --- a/README.md +++ b/README.md @@ -13,16 +13,16 @@ RootFS 安装、产品级命令策略、Swift Concurrency 隔离和界面。项 ## 当前阶段:Native ABI 过渡 -当前默认分支已发布 `v0.4.0-abi.5`,正在准备兼容性维护预发布 -`v0.4.0-abi.6`。它们都属于 **Stage1 native ABI 过渡**,不是稳定 `v0.4.0`, +当前默认分支已发布 `v0.4.0-abi.6`,正在准备兼容性维护预发布 +`v0.4.0-abi.7`。它们都属于 **Stage1 native ABI 过渡**,不是稳定 `v0.4.0`, 也不是完整 v0.4 Swift API。请同时区分下面四个版本面: -| 版本面 | 当前 `v0.4.0-abi.5` | 计划中的 `v0.4.0-abi.6` | +| 版本面 | 当前 `v0.4.0-abi.6` | 计划中的 `v0.4.0-abi.7` | | --- | --- | --- | -| 公开 C ABI | `ISH_EMBED_ABI_VERSION == 1`;兼容性符号已发布 | 仍为 ABI 1,不新增公开符号 | +| 公开 C ABI | `ISH_EMBED_ABI_VERSION == 1`;兼容性符号已发布 | 仍为 ABI 1,新增向后兼容的原子 rename 函数符号 | | 内部 wire protocol | host 与内嵌 supervisor 精确匹配 v4 | 仍为 v4;它不是公开 C ABI 版本 | -| `Package.swift` | 固定已公开的 `v0.4.0-abi.5` URL/checksum | 发布事务生成只改 manifest 的 release commit,固定到维护二进制 | -| Swift 源 | 保持 v0.3.3 ABI 兼容,不调用 retain/release | 保持相同 Swift API 与旧 ABI 用法 | +| `Package.swift` | 固定已公开的 `v0.4.0-abi.6` URL/checksum | 发布事务生成只改 manifest 的 release commit,固定到维护二进制 | +| Swift 源 | 保持 v0.3.3 ABI 兼容,不调用 retain/release | 新增类型化 rename API;通过 weak fallback 兼容旧 binary | Stage1 的 native runtime 已加入 session retain/release、可等待 kernel 线程、soft-halt、 严格 v4 协议和完整 session close 等底层能力。现有 Swift wrapper 刻意不调用新增 @@ -68,13 +68,13 @@ JIT 脏页一致性必须修改模拟器核心,无法只在 outer package 或 窄差异拥有独立 PR、CI 和精确 gitlink,PocketRoot 的构建与发布也因此可复现。我们不会在 本地直接改写别人维护的上游仓库;适合通用化的修复仍可回馈 [iSH upstream](https://github.com/ish-app/ish),但在上游接受并发布前由 fork 承担项目门禁。 -当前 `v0.4.0-abi.6` 源码变更不纳入 RootFS,也不提交任何预构建 +当前 `v0.4.0-abi.7` 源码变更不纳入 RootFS,也不提交任何预构建 XCFramework/guest binary;二进制只能在后续发布事务通过后生成和发布。 ## 安装状态 -`v0.4.0-abi.5` 已公开且当前 [`Package.swift`](Package.swift) 固定到它。 -`v0.4.0-abi.6` 发布前,manifest 继续指向这个已验证的资产,不会提前引用 404 URL。 +`v0.4.0-abi.6` 已公开且当前 [`Package.swift`](Package.swift) 固定到它。 +`v0.4.0-abi.7` 发布前,manifest 继续指向这个已验证的资产,不会提前引用 404 URL。 在 Xcode 的 **File → Add Package Dependencies…** 中使用: ```text @@ -84,8 +84,8 @@ https://github.com/jacklv-coder/ish-arm64-pkg 请选择明确包含 `libIshKernel.xcframework.zip`、对应源码归档,并且 manifest URL/checksum 与同一标签匹配的版本。业务工程不需要安装 Meson、Zig 或 LLVM。 -`v0.4.0-abi.6` 补全 Swift 参数封送和 stdin close 对原始 SPAWN deadline 的复用; -它不实现原生 Agent Loop,也不会 +`v0.4.0-abi.7` 新增无 shell、无 check-then-rename 竞争窗口的 guest 原子重命名, +并把目标已存在映射为 Swift 类型化错误;它不实现原生 Agent Loop,也不会 在 App 内安装 Codex CLI。Node.js/npm 如有需要仍由 RootFS/guest 包管理流程选择,不属于 runtime 的强制依赖。 @@ -106,6 +106,15 @@ let result = try instance.runOneshot( .init(argv: ["/bin/echo", "hello"], timeout: 10) ) print(String(decoding: result.stdoutData, as: UTF8.self)) + +do { + try instance.renameNoReplace( + from: "/workspace/draft.txt", + to: "/workspace/final.txt" + ) +} catch IshFilesystemError.destinationExists { + // 目标保持原样;调用方可以提示用户换一个名称。 +} ``` `IshSpawnOptions.timeout` 同时适用于 `runOneshot` 与 streaming `spawn`。有限超时从 diff --git a/Sources/CIshEmbed/CIshEmbed.c b/Sources/CIshEmbed/CIshEmbed.c index da49e12..e5d293e 100644 --- a/Sources/CIshEmbed/CIshEmbed.c +++ b/Sources/CIshEmbed/CIshEmbed.c @@ -1,4 +1,36 @@ -/* Empty translation unit so the SwiftPM target produces a real - * object file. The C ABI itself is implemented inside the prebuilt - * libIshKernel.xcframework; this target only re-exports its header - * to Swift via the modulemap in include/. */ +/* Source compatibility bridge for the short interval between merging a new + * Swift API and publishing its matching XCFramework. The weak fallback keeps + * Package.swift resolvable against the previous release; the bridge reports + * unsupported until the new binary is linked. */ + +#include "ishembed.h" +#include "IshEmbedSwiftShim.h" + +enum { ISH_SWIFT_ERR_UNSUPPORTED = -22 }; + +/* A weak definition keeps source builds linkable while Package.swift still + * points at the previous XCFramework. The new archive contains the strong + * implementation in the same member as the established runtime API, so it is + * loaded normally and overrides this fallback after publication. */ +__attribute__((weak)) +int ish_embed_rename_noreplace(ish_embed_instance_t *inst, + const char *source, + const char *destination, + uint32_t timeout_ms, + int32_t *out_guest_errno) { + (void)inst; + (void)source; + (void)destination; + (void)timeout_ms; + if (out_guest_errno) *out_guest_errno = 0; + return ISH_SWIFT_ERR_UNSUPPORTED; +} + +int ish_embed_swift_rename_noreplace(ish_embed_instance_t *inst, + const char *source, + const char *destination, + uint32_t timeout_ms, + int32_t *out_guest_errno) { + return ish_embed_rename_noreplace( + inst, source, destination, timeout_ms, out_guest_errno); +} diff --git a/Sources/CIshEmbed/include/IshEmbedSwiftShim.h b/Sources/CIshEmbed/include/IshEmbedSwiftShim.h new file mode 100644 index 0000000..6839d20 --- /dev/null +++ b/Sources/CIshEmbed/include/IshEmbedSwiftShim.h @@ -0,0 +1,22 @@ +#ifndef ISH_EMBED_SWIFT_SHIM_H +#define ISH_EMBED_SWIFT_SHIM_H + +#include "ishembed.h" + +#ifdef __cplusplus +extern "C" { +#endif + +/* SwiftPM source-target bridge. C embedders should call the public + * ish_embed_rename_noreplace symbol directly from abi.7 or newer. */ +int ish_embed_swift_rename_noreplace(ish_embed_instance_t *inst, + const char *source, + const char *destination, + uint32_t timeout_ms, + int32_t *out_guest_errno); + +#ifdef __cplusplus +} +#endif + +#endif diff --git a/Sources/CIshEmbed/include/module.modulemap b/Sources/CIshEmbed/include/module.modulemap index 2d92fb9..b724b07 100644 --- a/Sources/CIshEmbed/include/module.modulemap +++ b/Sources/CIshEmbed/include/module.modulemap @@ -1,4 +1,5 @@ module CIshEmbed { header "ishembed.h" + header "IshEmbedSwiftShim.h" export * } diff --git a/Sources/IshEmbed/IshEmbed.swift b/Sources/IshEmbed/IshEmbed.swift index c94102a..67c2364 100644 --- a/Sources/IshEmbed/IshEmbed.swift +++ b/Sources/IshEmbed/IshEmbed.swift @@ -38,6 +38,23 @@ public enum IshError: Error, CustomStringConvertible { } } +public enum IshFilesystemError: Error, Equatable, CustomStringConvertible { + /// The destination existed at the atomic rename point. Neither path was + /// replaced, and the source remains unchanged. + case destinationExists + /// A positive Linux guest errno returned by the filesystem operation. + case guestErrno(Int32) + + public var description: String { + switch self { + case .destinationExists: + return "The destination already exists." + case .guestErrno(let value): + return "Guest filesystem operation failed with Linux errno \(value)." + } + } +} + public struct IshSpawnOptions { public var argv: [String] public var cwd: String? @@ -106,6 +123,7 @@ public enum IshSessionEvent { // The C header exposes ISH_OK / ISH_ERR_* as an unnamed enum and // ISH_STREAM_* as #define ints; both arrive as Int32 in Swift. private let ishOK: Int32 = ISH_OK.rawValue +private let ishUnsupported: Int32 = -22 private let ishErrNotRunning: Int32 = ISH_ERR_NOT_RUNNING.rawValue private let ishErrAlreadyBooted: Int32 = ISH_ERR_ALREADY_BOOTED.rawValue private let ishErrNoSession: Int32 = ISH_ERR_NO_SESSION.rawValue @@ -188,6 +206,9 @@ struct IshLifecycleNativeCalls: @unchecked Sendable { UnsafeMutablePointer, IshSpawnTimeoutBudget) throws -> (result: Int32, session: OpaquePointer?) + let renameNoReplace: (OpaquePointer, UnsafePointer, + UnsafePointer, UInt32, + UnsafeMutablePointer) -> Int32 let sessionClose: (OpaquePointer) -> Void let freeBuffer: (UnsafeMutablePointer) -> Void @@ -216,6 +237,12 @@ struct IshLifecycleNativeCalls: @unchecked Sendable { let result = ish_embed_spawn(instance, opts, &session) return (result, session) }, + renameNoReplace: { instance, source, destination, timeoutMs, + guestErrno in + ish_embed_swift_rename_noreplace( + instance, source, destination, timeoutMs, guestErrno + ) + }, sessionClose: { session in ish_embed_session_close(session) }, @@ -336,6 +363,52 @@ public final class IshInstance: @unchecked Sendable { } } + /// Atomically renames a guest file or directory without replacing an + /// existing destination. Both paths are absolute inside the Linux guest. + /// This operation does not invoke a shell and does not perform a racy + /// existence check before rename. + public func renameNoReplace( + from source: String, + to destination: String, + timeout: TimeInterval? = 5 + ) throws { + guard source.first == "/", source.count > 1, + destination.first == "/", destination.count > 1, + !source.utf8.contains(0), !destination.utf8.contains(0) else { + throw IshError.from(ishErrInvalidArg) + } + let timeoutBudget = try IshSpawnTimeoutBudget( + timeout: timeout, + startedAt: ProcessInfo.processInfo.systemUptime + ) + let lease = try callGate.acquireCall() + defer { lease.release() } + let timeoutMs = try timeoutBudget.remainingMilliseconds( + at: ProcessInfo.processInfo.systemUptime + ) + var guestErrno: Int32 = 0 + let rc = source.withCString { sourceC in + destination.withCString { destinationC in + nativeCalls.renameNoReplace( + lease.raw, sourceC, destinationC, timeoutMs, &guestErrno + ) + } + } + if rc == ishUnsupported { + throw IshError.raw( + rc, + "Atomic no-replace rename requires IshEmbed v0.4.0-abi.7 or newer." + ) + } + if rc != ishOK { throw IshError.from(rc) } + if guestErrno == 17 { + throw IshFilesystemError.destinationExists + } + if guestErrno != 0 { + throw IshFilesystemError.guestErrno(guestErrno) + } + } + /// Spawn a streaming session. public func spawn(_ opts: IshSpawnOptions) throws -> IshSession { let timeoutBudget = try IshSpawnTimeoutBudget( diff --git a/Tests/IshEmbedTests/IshEmbedTests.swift b/Tests/IshEmbedTests/IshEmbedTests.swift index 57a46f8..630dcd2 100644 --- a/Tests/IshEmbedTests/IshEmbedTests.swift +++ b/Tests/IshEmbedTests/IshEmbedTests.swift @@ -3,6 +3,8 @@ import XCTest import CIshEmbed @testable import IshEmbed +private let ishUnsupported: Int32 = -22 + private final class LockedResults: @unchecked Sendable { private let lock = NSLock() private var values: [Int32] @@ -56,6 +58,8 @@ private final class LifecycleNativeHarness: @unchecked Sendable { (UnsafeMutablePointer) -> Int32 private let timeoutObservedBody: (UInt32) -> Void private let spawnTimeoutObservedBody: (UInt32) -> Void + private let renameBody: + (String, String, UInt32, UnsafeMutablePointer) -> Int32 private let sessionCloseBody: () -> Void private let bufferFreedBody: () -> Void private let lock = NSLock() @@ -63,6 +67,7 @@ private final class LifecycleNativeHarness: @unchecked Sendable { private var shutdownCalls = 0 private var oneshotCalls = 0 private var spawnCalls = 0 + private var renameCalls = 0 private var sessionCloseCalls = 0 private var freeBufferCalls = 0 private var oneshotResultWasZeroed = true @@ -75,6 +80,10 @@ private final class LifecycleNativeHarness: @unchecked Sendable { }, timeoutObservedBody: @escaping (UInt32) -> Void = { _ in }, spawnTimeoutObservedBody: @escaping (UInt32) -> Void = { _ in }, + renameBody: @escaping + (String, String, UInt32, UnsafeMutablePointer) -> Int32 = { + _, _, _, _ in ishUnsupported + }, sessionCloseBody: @escaping () -> Void = {}, bufferFreedBody: @escaping () -> Void = {}) { self.spawnResult = spawnResult @@ -82,6 +91,7 @@ private final class LifecycleNativeHarness: @unchecked Sendable { self.oneshotBody = oneshotBody self.timeoutObservedBody = timeoutObservedBody self.spawnTimeoutObservedBody = spawnTimeoutObservedBody + self.renameBody = renameBody self.sessionCloseBody = sessionCloseBody self.bufferFreedBody = bufferFreedBody } @@ -135,6 +145,18 @@ private final class LifecycleNativeHarness: @unchecked Sendable { return (spawnResult, spawnResult == 0 ? sessionRaw : nil) }, + renameNoReplace: { [self] _, source, destination, timeoutMs, + guestErrno in + lock.lock() + renameCalls += 1 + lock.unlock() + return renameBody( + String(cString: source), + String(cString: destination), + timeoutMs, + guestErrno + ) + }, sessionClose: { [self] _ in lock.lock() sessionCloseCalls += 1 @@ -151,10 +173,11 @@ private final class LifecycleNativeHarness: @unchecked Sendable { } func counts() -> (boot: Int, shutdown: Int, oneshot: Int, spawn: Int, + rename: Int, close: Int, freeBuffer: Int, resultWasZeroed: Bool) { lock.lock() defer { lock.unlock() } - return (bootCalls, shutdownCalls, oneshotCalls, spawnCalls, + return (bootCalls, shutdownCalls, oneshotCalls, spawnCalls, renameCalls, sessionCloseCalls, freeBufferCalls, oneshotResultWasZeroed) } @@ -253,6 +276,94 @@ final class IshEmbedTests: XCTestCase { XCTAssertEqual(native.counts().shutdown, 1) } + func testAtomicRenameMapsGuestResultsAndValidatesPaths() throws { + var observed: [(String, String, UInt32)] = [] + let native = LifecycleNativeHarness( + renameBody: { source, destination, timeoutMs, guestErrno in + observed.append((source, destination, timeoutMs)) + guestErrno.pointee = + destination.hasSuffix("exists") ? 17 : + destination.hasSuffix("denied") ? 13 : 0 + return 0 + }) + let instance = IshInstance(nativeCalls: native.nativeCalls()) + try instance.boot(.init(rootfsPath: "/unused-test-rootfs")) + + try instance.renameNoReplace( + from: "/workspace/source", + to: "/workspace/renamed" + ) + XCTAssertThrowsError( + try instance.renameNoReplace( + from: "/workspace/source", + to: "/workspace/exists" + ) + ) { error in + XCTAssertEqual(error as? IshFilesystemError, .destinationExists) + } + XCTAssertThrowsError( + try instance.renameNoReplace( + from: "/workspace/source", + to: "/workspace/denied" + ) + ) { error in + XCTAssertEqual(error as? IshFilesystemError, .guestErrno(13)) + } + XCTAssertThrowsError( + try instance.renameNoReplace(from: "relative", to: "/valid") + ) { error in + XCTAssertEqual(ishErrorCode(error), ISH_ERR_INVALID_ARG.rawValue) + } + + XCTAssertEqual(observed.count, 3) + XCTAssertEqual(observed.first?.0, "/workspace/source") + XCTAssertEqual(observed.first?.1, "/workspace/renamed") + XCTAssertTrue(observed.allSatisfy { $0.2 > 0 && $0.2 <= 5_000 }) + XCTAssertEqual(native.counts().rename, 3) + try instance.shutdown() + } + + func testAtomicRenameReportsOlderNativeBinaryClearly() throws { + let native = LifecycleNativeHarness() + let instance = IshInstance(nativeCalls: native.nativeCalls()) + try instance.boot(.init(rootfsPath: "/unused-test-rootfs")) + + XCTAssertThrowsError( + try instance.renameNoReplace(from: "/source", to: "/destination") + ) { error in + XCTAssertEqual(ishErrorCode(error), ishUnsupported) + XCTAssertTrue(String(describing: error).contains("abi.7")) + } + try instance.shutdown() + } + + func testAtomicRenameLeaseBlocksConcurrentShutdown() throws { + let entered = DispatchSemaphore(value: 0) + let release = DispatchSemaphore(value: 0) + let finished = DispatchSemaphore(value: 0) + let native = LifecycleNativeHarness( + renameBody: { _, _, _, guestErrno in + entered.signal() + release.wait() + guestErrno.pointee = 0 + return 0 + }) + let instance = IshInstance(nativeCalls: native.nativeCalls()) + try instance.boot(.init(rootfsPath: "/unused-test-rootfs")) + + DispatchQueue.global().async { + try? instance.renameNoReplace(from: "/source", to: "/destination") + finished.signal() + } + XCTAssertEqual(entered.wait(timeout: .now() + 1), .success) + XCTAssertThrowsError(try instance.shutdown()) { error in + XCTAssertEqual(ishErrorCode(error), ISH_ERR_BUSY.rawValue) + } + release.signal() + XCTAssertEqual(finished.wait(timeout: .now() + 1), .success) + try instance.shutdown() + } + func testFailedSpawnReleasesInstanceLease() throws { let native = LifecycleNativeHarness(spawnResult: -15) let instance = IshInstance(nativeCalls: native.nativeCalls()) diff --git a/c-tests/lifecycle_test.c b/c-tests/lifecycle_test.c index ddc4aeb..fad1f75 100644 --- a/c-tests/lifecycle_test.c +++ b/c-tests/lifecycle_test.c @@ -60,6 +60,7 @@ enum fake_mode { FAKE_SPAWN_ARGUMENT_BOUND, FAKE_WRITER_LOCK_HOLD, FAKE_WRITER_PRECOMMIT_DEADLINE, + FAKE_RENAME_NOREPLACE, }; enum malformed_event_kind { @@ -140,6 +141,11 @@ static int g_oneshot_terminate_status; static int g_oneshot_signal_attempts; static int g_oneshot_signal_status; static enum malformed_event_kind g_malformed_event_kind; +static atomic_int g_rename_spawn_valid; +static int g_rename_waiting; +static int g_release_rename; + +static void fake_emit_error(uint32_t sid, int32_t err, const char *message); static int io_write_full(int fd, const void *buf, size_t len) { const uint8_t *p = buf; @@ -201,6 +207,94 @@ static void fake_emit_exit(uint32_t sid, int32_t code, int32_t sig) { (void)fake_emit(ISH_FT_EXITED, 0, sid, body, sizeof(body)); } +static void fake_emit_stdout_record(uint32_t sid, const char *record) { + size_t record_len = strlen(record); + uint8_t body[8 + 32]; + if (record_len > sizeof(body) - 8) abort(); + ish_proto_put_u64(body, 1); + memcpy(body + 8, record, record_len); + (void)fake_emit(ISH_FT_STDOUT_DATA, ISH_FF_SEQ_PRESENT, sid, + body, (uint32_t)(8 + record_len)); +} + +static int fake_spawn_take_string(const uint8_t *body, size_t body_len, + size_t *offset, const uint8_t **bytes, + uint32_t *length) { + if (!body || !offset || !bytes || !length || + *offset > body_len || body_len - *offset < 4) + return -1; + *length = ish_proto_get_u32(body + *offset); + *offset += 4; + if (*offset > body_len || (size_t)*length > body_len - *offset) + return -1; + *bytes = body + *offset; + *offset += *length; + return 0; +} + +static int fake_spawn_string_equals(const uint8_t *bytes, uint32_t length, + const char *expected) { + size_t expected_len = strlen(expected); + return expected_len == length && + memcmp(bytes, expected, expected_len) == 0; +} + +static void fake_handle_rename_spawn(uint32_t sid, const uint8_t *body, + uint32_t body_len) { + size_t offset = 0; + const uint8_t *bytes = NULL; + uint32_t length = 0; + if (fake_spawn_take_string(body, body_len, &offset, &bytes, &length) < 0 || + !fake_spawn_string_equals(bytes, length, "/") || + offset > body_len || body_len - offset < 4) + goto invalid; + uint32_t argc = ish_proto_get_u32(body + offset); + offset += 4; + if (argc != 4) goto invalid; + + const uint8_t *argv[4]; + uint32_t argv_len[4]; + for (size_t i = 0; i < 4; i++) { + if (fake_spawn_take_string(body, body_len, &offset, + &argv[i], &argv_len[i]) < 0) + goto invalid; + } + if (!fake_spawn_string_equals(argv[0], argv_len[0], + ish_embed_bundled_supervisor_guest_path) || + !fake_spawn_string_equals(argv[1], argv_len[1], + "--rename-noreplace") || + !fake_spawn_string_equals(argv[2], argv_len[2], "/source")) + goto invalid; + + atomic_store(&g_rename_spawn_valid, 1); + if (fake_spawn_string_equals(argv[3], argv_len[3], "/exists")) + fake_emit_stdout_record(sid, "17\n"); + else if (fake_spawn_string_equals(argv[3], argv_len[3], "/malformed")) + fake_emit_stdout_record(sid, "oops\n"); + else if (fake_spawn_string_equals(argv[3], argv_len[3], "/oversized")) + fake_emit_stdout_record(sid, "00000000000\n"); + else if (fake_spawn_string_equals(argv[3], argv_len[3], "/renamed")) + fake_emit_stdout_record(sid, "0\n"); + else if (fake_spawn_string_equals(argv[3], argv_len[3], "/timeout")) + return; + else if (fake_spawn_string_equals(argv[3], argv_len[3], "/blocked")) { + pthread_mutex_lock(&g_fake_lock); + g_rename_waiting = 1; + pthread_cond_broadcast(&g_fake_cond); + while (!g_release_rename) + pthread_cond_wait(&g_fake_cond, &g_fake_lock); + pthread_mutex_unlock(&g_fake_lock); + fake_emit_stdout_record(sid, "0\n"); + } else + goto invalid; + fake_emit_exit(sid, 0, 0); + return; + +invalid: + atomic_store(&g_rename_spawn_valid, 0); + fake_emit_error(sid, EPROTO, "invalid rename helper spawn"); +} + static void fake_emit_error(uint32_t sid, int32_t err, const char *message) { size_t message_len = strlen(message); uint8_t body[8 + 64]; @@ -429,6 +523,8 @@ static void *fake_kernel_main(void *unused) { } while (fake_read(&type, &flags, &sid, &body, &body_len) == 0) { + if (type == ISH_FT_SPAWN && g_mode == FAKE_RENAME_NOREPLACE) + fake_handle_rename_spawn(sid, body, body_len); free(body); body = NULL; if (type == ISH_FT_SPAWN) { @@ -3372,6 +3468,125 @@ static int test_oneshot_output_limit(void) { return 0; } +struct rename_call_args { + ish_embed_instance_t *inst; + int rc; + int32_t guest_errno; +}; + +static void *rename_call_main(void *opaque) { + struct rename_call_args *args = opaque; + args->guest_errno = -1; + args->rc = ish_embed_rename_noreplace( + args->inst, "/source", "/blocked", 2000, &args->guest_errno); + return NULL; +} + +static int test_rename_noreplace_api(void) { + g_mode = FAKE_RENAME_NOREPLACE; + atomic_store(&g_rename_spawn_valid, 0); + g_rename_waiting = 0; + g_release_rename = 0; + ish_embed_instance_t *inst = boot_instance(); + int32_t guest_errno = -1; + + int rc = ish_embed_rename_noreplace( + inst, "relative", "/renamed", 1000, &guest_errno); + if (rc != ISH_ERR_INVALID_ARG || guest_errno != -1) { + fprintf(stderr, "rename relative validation: rc=%d errno=%d\n", + rc, guest_errno); + return 1; + } + + rc = ish_embed_rename_noreplace( + inst, "/source", "/exists", 1000, &guest_errno); + int rename_spawn_valid = atomic_load(&g_rename_spawn_valid); + if (rc != ISH_OK || guest_errno != EEXIST || !rename_spawn_valid) { + fprintf(stderr, "rename existing destination: rc=%d errno=%d valid=%d\n", + rc, guest_errno, rename_spawn_valid); + return 1; + } + + atomic_store(&g_rename_spawn_valid, 0); + rc = ish_embed_rename_noreplace( + inst, "/source", "/renamed", 1000, &guest_errno); + rename_spawn_valid = atomic_load(&g_rename_spawn_valid); + if (rc != ISH_OK || guest_errno != 0 || !rename_spawn_valid) { + fprintf(stderr, "rename success: rc=%d errno=%d valid=%d\n", + rc, guest_errno, rename_spawn_valid); + return 1; + } + + atomic_store(&g_rename_spawn_valid, 0); + rc = ish_embed_rename_noreplace( + inst, "/source", "/malformed", 1000, &guest_errno); + rename_spawn_valid = atomic_load(&g_rename_spawn_valid); + if (rc != ISH_ERR_PROTOCOL || guest_errno != 0 || + !rename_spawn_valid) { + fprintf(stderr, "rename malformed record: rc=%d errno=%d valid=%d\n", + rc, guest_errno, rename_spawn_valid); + return 1; + } + + atomic_store(&g_rename_spawn_valid, 0); + rc = ish_embed_rename_noreplace( + inst, "/source", "/oversized", 1000, &guest_errno); + rename_spawn_valid = atomic_load(&g_rename_spawn_valid); + if (rc != ISH_ERR_PROTOCOL || guest_errno != 0 || + !rename_spawn_valid) { + fprintf(stderr, "rename oversized record: rc=%d errno=%d valid=%d\n", + rc, guest_errno, rename_spawn_valid); + return 1; + } + + atomic_store(&g_rename_spawn_valid, 0); + rc = ish_embed_rename_noreplace( + inst, "/source", "/timeout", 25, &guest_errno); + rename_spawn_valid = atomic_load(&g_rename_spawn_valid); + if (rc != ISH_ERR_TIMEOUT || guest_errno != 0 || + !rename_spawn_valid) { + fprintf(stderr, "rename timeout: rc=%d errno=%d valid=%d\n", + rc, guest_errno, rename_spawn_valid); + return 1; + } + + struct rename_call_args rename_args = { + .inst = inst, + .rc = ISH_ERR_INTERNAL, + .guest_errno = -1, + }; + pthread_t rename_thread; + if (pthread_create(&rename_thread, NULL, rename_call_main, + &rename_args) != 0 || + !wait_fake_flag(&g_rename_waiting, 1000)) { + fprintf(stderr, "rename concurrent call did not block\n"); + return 1; + } + rc = ish_embed_shutdown(inst, 10); + if (rc != ISH_ERR_BUSY) { + fprintf(stderr, "rename concurrent shutdown: rc=%d\n", rc); + return 1; + } + pthread_mutex_lock(&g_fake_lock); + g_release_rename = 1; + pthread_cond_broadcast(&g_fake_cond); + pthread_mutex_unlock(&g_fake_lock); + if (pthread_join(rename_thread, NULL) != 0 || + rename_args.rc != ISH_OK || rename_args.guest_errno != 0) { + fprintf(stderr, "rename concurrent completion: rc=%d errno=%d\n", + rename_args.rc, rename_args.guest_errno); + return 1; + } + + rc = ish_embed_shutdown(inst, 2000); + if (rc != ISH_OK) { + fprintf(stderr, "rename shutdown: rc=%d\n", rc); + return 1; + } + fprintf(stderr, "atomic no-replace rename API: OK\n"); + return 0; +} + int main(int argc, char **argv) { if (argc == 3 && strcmp(argv[1], "malformed-event") == 0) { if (strcmp(argv[2], "spawned") == 0) @@ -3392,7 +3607,7 @@ int main(int argc, char **argv) { return 2; } if (argc != 2) { - fprintf(stderr, "usage: %s boot-timeout|bad-hello-ack|install-failure|bundled-supervisor-digest-mismatch|bundled-supervisor-path-mismatch|custom-supervisor|boot-null-output|stdin-close-order|control-frame-limit|control-critical-close|control-same-session-close|control-exited-same-session-close|signal-close-order|resize-close-order|terminate-close-order|control-critical-oneshot|control-preblocked-oneshot|control-byte-limit|control-byte-reserve|control-spawn-gate|control-oneshot-spawn-lock|control-streaming-spawn-lock|control-finite-streaming|control-finite-streaming-write|control-streaming-queue-deadline|control-streaming-stdin-deadline|control-streaming-precommit-deadline|streaming-instance-gate|supervisor-error|close-race|backlog|frame-backlog|backlog-control-pressure|borrow-shutdown|double-shutdown|active-call|broken-control|protocol-fatal|protocol-fatal-control-pressure|malformed-event TYPE|output-allocation-failure|spawn-argument-bound|shutdown-drain|log-backpressure|oneshot-timeout|oneshot-output\n", argv[0]); + fprintf(stderr, "usage: %s boot-timeout|bad-hello-ack|install-failure|bundled-supervisor-digest-mismatch|bundled-supervisor-path-mismatch|custom-supervisor|boot-null-output|stdin-close-order|control-frame-limit|control-critical-close|control-same-session-close|control-exited-same-session-close|signal-close-order|resize-close-order|terminate-close-order|control-critical-oneshot|control-preblocked-oneshot|control-byte-limit|control-byte-reserve|control-spawn-gate|control-oneshot-spawn-lock|control-streaming-spawn-lock|control-finite-streaming|control-finite-streaming-write|control-streaming-queue-deadline|control-streaming-stdin-deadline|control-streaming-precommit-deadline|streaming-instance-gate|supervisor-error|close-race|backlog|frame-backlog|backlog-control-pressure|borrow-shutdown|double-shutdown|active-call|broken-control|protocol-fatal|protocol-fatal-control-pressure|malformed-event TYPE|output-allocation-failure|spawn-argument-bound|shutdown-drain|log-backpressure|oneshot-timeout|oneshot-output|rename-noreplace\n", argv[0]); return 2; } if (strcmp(argv[1], "boot-timeout") == 0) return test_boot_timeout_cleanup(); @@ -3452,6 +3667,7 @@ int main(int argc, char **argv) { if (strcmp(argv[1], "log-backpressure") == 0) return test_log_sink_backpressure(); if (strcmp(argv[1], "oneshot-timeout") == 0) return test_oneshot_hard_timeout(); if (strcmp(argv[1], "oneshot-output") == 0) return test_oneshot_output_limit(); + if (strcmp(argv[1], "rename-noreplace") == 0) return test_rename_noreplace_api(); fprintf(stderr, "unknown test: %s\n", argv[1]); return 2; } diff --git a/c-tests/supervisor_stdin_test.c b/c-tests/supervisor_stdin_test.c index 50a5358..c9f81a8 100644 --- a/c-tests/supervisor_stdin_test.c +++ b/c-tests/supervisor_stdin_test.c @@ -16,6 +16,7 @@ int (*ishsv_test_waitid_hook)(idtype_t, id_t, siginfo_t *, int); pid_t (*ishsv_test_waitpid_hook)(pid_t, int *, int); int (*ishsv_test_kill_hook)(pid_t, int); pid_t (*ishsv_test_tcgetpgrp_hook)(int); +int (*ishsv_test_rename_noreplace_hook)(const char *, const char *); #if defined(__linux__) int (*ishsv_test_adopted_scan_hook)(struct proc_child_identity *); #endif @@ -51,6 +52,59 @@ static void read_expected_frame(int fd, uint8_t expected_type, if (len > 0) CHECK(read_full(fd, payload, len) == 0); } +static int rename_hook_result; +static int rename_hook_errno; +static char rename_hook_source[128]; +static char rename_hook_destination[128]; + +static int capture_rename_noreplace(const char *source, + const char *destination) { + snprintf(rename_hook_source, sizeof(rename_hook_source), "%s", source); + snprintf(rename_hook_destination, sizeof(rename_hook_destination), "%s", + destination); + errno = rename_hook_errno; + return rename_hook_result; +} + +static void run_rename_helper_case(int result, int error, + const char *expected_record) { + int output[2]; + CHECK(pipe(output) == 0); + int saved_stdout = dup(STDOUT_FILENO); + CHECK(saved_stdout >= 0); + CHECK(dup2(output[1], STDOUT_FILENO) == STDOUT_FILENO); + close(output[1]); + + rename_hook_result = result; + rename_hook_errno = error; + rename_hook_source[0] = '\0'; + rename_hook_destination[0] = '\0'; + ishsv_test_rename_noreplace_hook = capture_rename_noreplace; + char *argv[] = { + "ishsv", "--rename-noreplace", "/workspace/source", + "/workspace/destination", NULL, + }; + CHECK(run_rename_noreplace_helper(4, argv) == 0); + CHECK(dup2(saved_stdout, STDOUT_FILENO) == STDOUT_FILENO); + close(saved_stdout); + + char record[32] = {0}; + ssize_t count = read(output[0], record, sizeof(record)); + close(output[0]); + CHECK(count == (ssize_t)strlen(expected_record)); + CHECK(memcmp(record, expected_record, (size_t)count) == 0); + CHECK(strcmp(rename_hook_source, "/workspace/source") == 0); + CHECK(strcmp(rename_hook_destination, "/workspace/destination") == 0); + ishsv_test_rename_noreplace_hook = NULL; +} + +static void test_rename_noreplace_helper(void) { + run_rename_helper_case(0, 0, "0\n"); + run_rename_helper_case(-1, EEXIST, "17\n"); + char *bad_argv[] = {"ishsv", "--rename-noreplace", "/source", NULL}; + CHECK(run_rename_noreplace_helper(3, bad_argv) == 64); +} + /* Exercise the production main loop across real host pipes. PID 1 starts * before the host writer in the app, so deliberately delay HELLO and prove * the blocking handshake does not mistake EAGAIN for EOF/failure. */ @@ -71,7 +125,8 @@ static void test_delayed_hello_full_main(void) { close(null_fd); close(control[0]); close(events[1]); - _exit(ishsv_program_main()); + char *argv[] = {"ishsv", NULL}; + _exit(ishsv_program_main(1, argv)); } close(control[0]); @@ -168,7 +223,8 @@ static void test_output_flood_does_not_starve_control(void) { close(null_fd); close(control[0]); close(events[1]); - _exit(ishsv_program_main()); + char *argv[] = {"ishsv", NULL}; + _exit(ishsv_program_main(1, argv)); } close(control[0]); @@ -1441,6 +1497,7 @@ static void test_setsid_double_fork_escape_is_cleaned_before_exit(void) { int main(void) { signal(SIGPIPE, SIG_IGN); + test_rename_noreplace_helper(); test_delayed_hello_full_main(); test_output_flood_does_not_starve_control(); test_spawn_parser_rejects_wrapping_lengths(); diff --git a/c-tests/swift_bridge_smoke.c b/c-tests/swift_bridge_smoke.c new file mode 100644 index 0000000..0f1b240 --- /dev/null +++ b/c-tests/swift_bridge_smoke.c @@ -0,0 +1,17 @@ +/* Link-only probe for the Swift source target's weak compatibility fallback. + * + * ish_embed_strerror forces the established host/ishembed archive member into + * the final link. That same member contains the strong rename implementation, + * which must override CIshEmbed.c's fallback definition. + */ + +#include "ishembed.h" +#include "IshEmbedSwiftShim.h" + +int main(void) { + const char *status = ish_embed_strerror(ISH_OK); + int32_t guest_errno = 0; + int rc = ish_embed_swift_rename_noreplace( + NULL, "/source", "/destination", 1, &guest_errno); + return status != NULL && rc == ISH_ERR_INVALID_ARG ? 0 : 1; +} diff --git a/docs/README.en.md b/docs/README.en.md index 8917a97..fd21a30 100644 --- a/docs/README.en.md +++ b/docs/README.en.md @@ -14,13 +14,13 @@ primary language; the English mirrors support collaboration and release review. authoritative. Stage1 uses exact-match v4 between host and embedded supervisor. 3. **Swift source and manifest binary**: Stage1 Swift remains v0.3.3-ABI compatible and does not call retain/release. The manifest currently pins the - public `v0.4.0-abi.5`; the release transaction moves it to the - `v0.4.0-abi.6` maintenance binary only after those assets are public and + public `v0.4.0-abi.6`; the release transaction moves it to the + `v0.4.0-abi.7` maintenance binary only after those assets are public and verified. 4. **RootFS and PocketRoot**: RootFS is an independent asset and PocketRoot is the product layer. Neither is completed automatically by a runtime PR or Release. -`v0.4.0-abi.5` and its `v0.4.0-abi.6` maintenance release are native-first +`v0.4.0-abi.6` and its `v0.4.0-abi.7` maintenance release are native-first transition prereleases, not stable v0.4. The complete Swift lifecycle, typed statuses, Terminal callback queue, and VT parser changes belong to Stage2. @@ -44,7 +44,7 @@ statuses, Terminal callback queue, and VT parser changes belong to Stage2. | [Architecture and lifecycle](architecture.en.md) | How is the runtime implemented? How do ABI 1 and wire v4 differ? | | [Testing and acceptance](testing.en.md) | What do native, sanitizer, Swift/iOS 18, documentation, and supply-chain gates prove? | | [Troubleshooting](troubleshooting.en.md) | Where should boot, link, protocol, output, shutdown, or release diagnosis start? | -| [Release transaction](releasing.en.md) | Why does the merged maintenance source still pin `v0.4.0-abi.5`, and when does it become `v0.4.0-abi.6`? | +| [Release transaction](releasing.en.md) | Why does the merged maintenance source still pin `v0.4.0-abi.6`, and when does it become `v0.4.0-abi.7`? | | [Changelog](../CHANGELOG.en.md) | What is Stage1's scope and compatibility boundary? | ## Architecture on one page @@ -99,7 +99,7 @@ fixes can still go upstream. See the A direct-chain or RET target returns to the dispatcher only when it intersects pending dirty code pages; data-only writes may keep chaining. - RootFS is outside the package/Release and must not enter Corresponding Source. -- The current `v0.4.0-abi.6` source change carries no RootFS or prebuilt binary; +- The current `v0.4.0-abi.7` source change carries no RootFS or prebuilt binary; a later release transaction must produce the XCFramework. ## Authoritative sources diff --git a/docs/README.md b/docs/README.md index b23e46f..508f1e8 100644 --- a/docs/README.md +++ b/docs/README.md @@ -12,12 +12,12 @@ 2. **内部 wire protocol**:权威定义为 [`protocol/proto.h`](../protocol/proto.h), Stage1 是 host 与内嵌 supervisor 精确匹配的 v4。 3. **Swift 源与 manifest binary**:Stage1 Swift 保持 v0.3.3 ABI 兼容且不调用 - retain/release。manifest 当前固定已公开的 `v0.4.0-abi.5`;只有 - `v0.4.0-abi.6` 资产公开并验证后,发布事务才将其切到维护 binary。 + retain/release。manifest 当前固定已公开的 `v0.4.0-abi.6`;只有 + `v0.4.0-abi.7` 资产公开并验证后,发布事务才将其切到维护 binary。 4. **RootFS 与 PocketRoot**:RootFS 是独立资产;PocketRoot 是上层产品。两者均不因 runtime PR 或 Release 自动完成。 -`v0.4.0-abi.5` 与维护版本 `v0.4.0-abi.6` 都是 native-first 过渡预发布,不是稳定 +`v0.4.0-abi.6` 与维护版本 `v0.4.0-abi.7` 都是 native-first 过渡预发布,不是稳定 v0.4。完整 Swift lifecycle、类型化状态、Terminal callback 队列和 VT parser 改造 属于 Stage2。 @@ -39,7 +39,7 @@ v0.4。完整 Swift lifecycle、类型化状态、Terminal callback 队列和 VT | [架构与生命周期](architecture.md) | runtime 怎样实现?ABI 1 与 wire v4 有什么区别? | | [测试与验收](testing.md) | native、sanitizer、Swift/iOS 18、文档与供应链门禁各证明什么? | | [故障排查](troubleshooting.md) | boot、链接、协议、输出、shutdown 或发布失败时从哪里查? | -| [发布事务](releasing.md) | 为什么维护源码合入后 manifest 仍是 `v0.4.0-abi.5`?何时变成 `v0.4.0-abi.6`? | +| [发布事务](releasing.md) | 为什么维护源码合入后 manifest 仍是 `v0.4.0-abi.6`?何时变成 `v0.4.0-abi.7`? | | [变更日志](../CHANGELOG.md) | Stage1 的范围和兼容边界是什么? | ## 一页架构 @@ -85,7 +85,7 @@ Swift 对象不是另一套 runtime。它们包装 C handle;C 层管理线程 - JIT 单页写及显式 `invalidate_page` 按精确页过滤;仅多页哈希位图可能因碰撞保守多 失效。下一直链/RET 目标命中待处理代码脏页时才回到 dispatcher;纯数据写可继续直链。 - RootFS 不属于 package/release,禁止将其混入 Corresponding Source。 -- 当前 `v0.4.0-abi.6` 源码变更不携带 RootFS 或预构建二进制;XCFramework 必须由 +- 当前 `v0.4.0-abi.7` 源码变更不携带 RootFS 或预构建二进制;XCFramework 必须由 后续发布事务生成。 ## 权威来源 diff --git a/docs/releasing.en.md b/docs/releasing.en.md index 9027b10..b8d47f1 100644 --- a/docs/releasing.en.md +++ b/docs/releasing.en.md @@ -3,29 +3,29 @@ [简体中文](releasing.md) | English This guide is for maintainers publishing the XCFramework and matching -Corresponding Source. `v0.4.0-abi.5` is already public; the only next tag +Corresponding Source. `v0.4.0-abi.6` is already public; the only next tag allowed for preparation is the compatible maintenance release -`v0.4.0-abi.6`. It remains an ABI-transition prerelease that is **not stable +`v0.4.0-abi.7`. It remains an ABI-transition prerelease that is **not stable v0.4.0**. Publication creates a public GitHub Release and updates the default branch, so run it only with explicit release authorization. -## State before and after `v0.4.0-abi.6` publication +## State before and after `v0.4.0-abi.7` publication ### After the maintenance PR merges, before Release publication -- `Package.swift` still pins the published `v0.4.0-abi.5` URL/checksum; +- `Package.swift` still pins the published `v0.4.0-abi.6` URL/checksum; - Swift source remains v0.3.3-ABI compatible and does not call retain/release; - repository source contains the published abi.2 procfs/task lifecycle fixes, - abi.3 guest `uname` field bounds, abi.4 internal-SIGUSR1 fix, and abi.5 finite - streaming control-path deadline, plus the pending abi.6 Swift-marshalling and - stdin-close original-deadline reuse fixes; -- there is no installable `v0.4.0-abi.6` binary. + abi.3 guest `uname` field bounds, abi.4 internal-SIGUSR1 fix, abi.5 finite + streaming control-path deadline, and abi.6 Swift-marshalling/stdin-close + deadline reuse, plus the pending abi.7 guest-atomic no-replace rename; +- there is no installable `v0.4.0-abi.7` binary. This intermediate state is intentional: the default branch never advertises an unpublished asset URL that returns 404. -### After successful `v0.4.0-abi.6` publication +### After successful `v0.4.0-abi.7` publication - the release commit changes only `Package.swift`, pinning the new XCFramework URL/checksum; @@ -75,7 +75,7 @@ scripts/test-swift-ios.sh --local-binary ``` `--manifest-binary` proves that Stage1 Swift still links the currently pinned -`v0.4.0-abi.5` binary. `--local-binary` proves that the same Swift source links +`v0.4.0-abi.6` binary. `--local-binary` proves that the same Swift source links the maintenance XCFramework. Both boundaries are required. ## Execute @@ -83,7 +83,7 @@ the maintenance XCFramework. Both boundaries are required. After confirming that the tag is absent and publication is authorized: ```sh -scripts/release.sh v0.4.0-abi.6 +scripts/release.sh v0.4.0-abi.7 ``` The script derives GitHub `prerelease=true` from the SemVer suffix. Only @@ -91,7 +91,7 @@ The script derives GitHub `prerelease=true` from the SemVer suffix. Only that this is not stable v0.4, describes native lifecycle/retain-release/ join-soft-halt/wire v4, and records the Swift and RootFS boundaries. In addition to strict SemVer validation, the Stage1 policy rejects every tag -except `v0.4.0-abi.6`. Reusing `v0.4.0-abi.5` or accidentally entering +except `v0.4.0-abi.7`. Reusing `v0.4.0-abi.6` or accidentally entering `v0.4.0` therefore fails before any tag, draft, or asset is written. Do not substitute `v0.4.0`. A stable tag must wait for a separate decision after @@ -148,9 +148,9 @@ and explicit owner authorization. ## Post-publication acceptance ```sh -gh release view v0.4.0-abi.6 --repo jacklv-coder/ish-arm64-pkg +gh release view v0.4.0-abi.7 --repo jacklv-coder/ish-arm64-pkg git fetch origin --tags -git show v0.4.0-abi.6:Package.swift +git show v0.4.0-abi.7:Package.swift git pull --ff-only origin main scripts/test-swift-ios.sh --manifest-binary ``` @@ -184,8 +184,8 @@ manual recovery. ## PocketRoot upgrade gate -PocketRoot may move its dependency from `v0.4.0-abi.5` to the maintenance -release only after the public `v0.4.0-abi.6` assets, manifest update, and +PocketRoot may move its dependency from `v0.4.0-abi.6` to the maintenance +release only after the public `v0.4.0-abi.7` assets, manifest update, and post-publication real link all pass, followed by its Xcode 16/iOS 18 gates. Stage2 and a native Agent Loop are outside this release. Either still requires an independent plan, review, tests, documentation, and release decision. diff --git a/docs/releasing.md b/docs/releasing.md index 55ce1f3..8d16a5f 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -2,25 +2,26 @@ 简体中文|[English](releasing.en.md) -本文用于维护者发布 XCFramework 与匹配的 Corresponding Source。`v0.4.0-abi.5` -已经公开;当前唯一允许准备的下一个标签是兼容性维护版本 `v0.4.0-abi.6`。它仍是 ABI +本文用于维护者发布 XCFramework 与匹配的 Corresponding Source。`v0.4.0-abi.6` +已经公开;当前唯一允许准备的下一个标签是兼容性维护版本 `v0.4.0-abi.7`。它仍是 ABI 过渡 prerelease,**不是稳定 v0.4.0**。执行发布会创建公开 GitHub Release 和更新 默认分支,必须在获得明确发布授权后进行。 -## `v0.4.0-abi.6` 发布前后状态 +## `v0.4.0-abi.7` 发布前后状态 ### 维护 PR 合入后、Release 发布前 -- `Package.swift` 仍固定已发布的 `v0.4.0-abi.5` URL/checksum; +- `Package.swift` 仍固定已发布的 `v0.4.0-abi.6` URL/checksum; - Swift 源保持 v0.3.3 ABI 兼容,不调用 retain/release; - 仓库源码包含已发布的 abi.2 procfs/task 生命周期修复、abi.3 guest `uname` 字段边界修复、abi.4 内部 SIGUSR1 解屏蔽修复、abi.5 有限 streaming control-path - deadline,以及待发布 abi.6 的 Swift 参数封送与 stdin-close 原始 deadline 复用修复; -- 没有可供使用方安装的 `v0.4.0-abi.6` binary。 + deadline、abi.6 Swift 参数封送与 stdin-close deadline 复用,以及待发布 abi.7 的 + guest 原子 no-replace rename; +- 没有可供使用方安装的 `v0.4.0-abi.7` binary。 这个中间状态是刻意设计的:默认分支不会先暴露一个尚未公开、会返回 404 的资产 URL。 -### `v0.4.0-abi.6` 成功发布后 +### `v0.4.0-abi.7` 成功发布后 - release commit 只改 `Package.swift`,固定到新 XCFramework URL/checksum; - GitHub prerelease 包含 `libIshKernel.xcframework.zip` 与 @@ -63,7 +64,7 @@ scripts/verify-ios-artifact.sh scripts/test-swift-ios.sh --local-binary ``` -`--manifest-binary` 证明 Stage1 Swift 仍能链接当前固定的 `v0.4.0-abi.5` binary; +`--manifest-binary` 证明 Stage1 Swift 仍能链接当前固定的 `v0.4.0-abi.6` binary; `--local-binary` 证明相同 Swift 能链接待发布的 ABI 过渡 XCFramework。两者缺一不可。 ## 执行 @@ -71,14 +72,14 @@ scripts/test-swift-ios.sh --local-binary 确认标签不存在且获得发布授权后: ```sh -scripts/release.sh v0.4.0-abi.6 +scripts/release.sh v0.4.0-abi.7 ``` 脚本根据 SemVer 后缀设置 GitHub `prerelease=true`。只有 `v*-abi.*` 标签会附加专用 中英文说明,明确它不是稳定 v0.4,列出 native lifecycle/retain-release/ join-soft-halt/wire v4,并说明 Swift 和 RootFS 边界。 -除了 SemVer 检查,Stage1 版本策略还会硬性拒绝除 `v0.4.0-abi.6` 以外的任何标签。 -因此重用 `v0.4.0-abi.5` 或误输入 `v0.4.0`,都会在任何 tag、draft 或资产写入前失败。 +除了 SemVer 检查,Stage1 版本策略还会硬性拒绝除 `v0.4.0-abi.7` 以外的任何标签。 +因此重用 `v0.4.0-abi.6` 或误输入 `v0.4.0`,都会在任何 tag、draft 或资产写入前失败。 不要用 `v0.4.0` 代替过渡标签。稳定标签必须等 Stage2 合入、迁移与回归完成后另行决定。 @@ -125,9 +126,9 @@ LICENSE/NOTICE、对应源码、SBOM、PocketRoot manifest 更新和负责人明 ## 发布后验收 ```sh -gh release view v0.4.0-abi.6 --repo jacklv-coder/ish-arm64-pkg +gh release view v0.4.0-abi.7 --repo jacklv-coder/ish-arm64-pkg git fetch origin --tags -git show v0.4.0-abi.6:Package.swift +git show v0.4.0-abi.7:Package.swift git pull --ff-only origin main scripts/test-swift-ios.sh --manifest-binary ``` @@ -158,7 +159,7 @@ scripts/test-swift-ios.sh --manifest-binary ## PocketRoot 升级条件 -只有 `v0.4.0-abi.6` 公开资产、manifest 更新和发布后真链接全部通过,PocketRoot 才能 -把依赖从 `v0.4.0-abi.5` 更新到该维护版本并重跑 Xcode 16/iOS 18 门禁。Stage2 与 +只有 `v0.4.0-abi.7` 公开资产、manifest 更新和发布后真链接全部通过,PocketRoot 才能 +把依赖从 `v0.4.0-abi.6` 更新到该维护版本并重跑 Xcode 16/iOS 18 门禁。Stage2 与 原生 Agent Loop 均不在本次发布范围内;未来启用时仍需独立计划、CR、测试、文档和 发布决策。 diff --git a/docs/testing.en.md b/docs/testing.en.md index 1982465..c9929fb 100644 --- a/docs/testing.en.md +++ b/docs/testing.en.md @@ -16,25 +16,25 @@ wire v4, and release supply chain. | Native integration | `internal-signal-mask`, `procfs_test`, `ishembed_smoke` | internal SIGUSR1 masks on embedded/guest task threads, fakefs, spawn, procfs, a real guest `uname -a`, and the general command path | RootFS provenance/license is trustworthy; compatibility with a particular user tool | | Sanitizers | ASan/UBSan and TSan where applicable | bounds, UAF, undefined behavior, and races on covered paths | every schedule is defect-free | | RootFS-free Swift | instance/session gates, shutdown retry, public API smoke | oneshot/session leases prevent old-ABI UAF, failure keeps the handle, old public signatures compile | every C call is cancellable or close is always bounded | -| Swift manifest real link | `test-swift-ios.sh --manifest-binary` | Stage1 Swift links the current `v0.4.0-abi.5` binary | `v0.4.0-abi.6` fixes are public | +| Swift manifest real link | `test-swift-ios.sh --manifest-binary` | Stage1 Swift links the current `v0.4.0-abi.6` binary | `v0.4.0-abi.7` fixes are public | | Swift local real link | `test-swift-ios.sh --local-binary` | the same Swift links the maintenance XCFramework | GitHub assets are published | | XCFramework | `build-ios.sh` plus symbol/final-link checks | device/simulator arm64, minimum iOS 18, required symbols | product app behavior | | Docs/scripts | positive/negative docs gates, shell syntax, policy tests | bilingual links, diagnostics, release-notes/version/tag/source policy | documentation equals implementation | ## Confirm the Stage1 state first -Before `v0.4.0-abi.6` publication, all of these should be true: +Before `v0.4.0-abi.7` publication, all of these should be true: - `ISH_EMBED_ABI_VERSION` is 1; - `ISH_PROTO_VERSION` is 4; - Swift source does not call `ish_embed_session_retain/release`; -- `Package.swift` still pins the public `v0.4.0-abi.5`; +- `Package.swift` still pins the public `v0.4.0-abi.6`; - the locally built XCFramework exports retain/release and required join/soft-halt symbols; - RootFS content is absent from Git diff, XCFramework, source archive, and Release manifest. -Only after publication should “manifest pins `v0.4.0-abi.6`” become the expected +Only after publication should “manifest pins `v0.4.0-abi.7`” become the expected state. Do not apply that expectation to a correct pre-publication tree that -still references the verified `v0.4.0-abi.5`. +still references the verified `v0.4.0-abi.6`. ## Fast metadata and script gates diff --git a/docs/testing.md b/docs/testing.md index d51cb5b..f227850 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -15,24 +15,24 @@ iOS 18 二进制、内部 wire v4 和发布供应链处于同一可解释状态 | native 集成 | `internal-signal-mask`、`procfs_test`、`ishembed_smoke` | 嵌入/guest task 内部 SIGUSR1 mask、fakefs、spawn、procfs、真实 guest `uname -a`、通用命令链路 | RootFS 来源/许可可信;特定用户工具兼容性 | | sanitizer | ASan/UBSan,必要时 TSan | 已覆盖路径上的越界、UAF、未定义行为和数据竞争 | 所有调度组合都无缺陷 | | Swift RootFS-free | instance/session gate、shutdown retry、公开 API smoke | oneshot/session lease 阻止旧 ABI UAF、失败保留 handle、旧公开签名可编译 | 任意 C 调用都可取消或 close 始终有界 | -| Swift manifest 真链接 | `test-swift-ios.sh --manifest-binary` | Stage1 Swift 与当前 `v0.4.0-abi.5` binary 链接 | `v0.4.0-abi.6` 修复已公开 | +| Swift manifest 真链接 | `test-swift-ios.sh --manifest-binary` | Stage1 Swift 与当前 `v0.4.0-abi.6` binary 链接 | `v0.4.0-abi.7` 修复已公开 | | Swift local 真链接 | `test-swift-ios.sh --local-binary` | 同一 Swift 与待发布维护 XCFramework 链接 | GitHub 资产已发布 | | XCFramework | `build-ios.sh` + symbol/final-link 检查 | device/simulator arm64、最低 iOS 18、必需符号 | App 产品逻辑 | | 文档/脚本 | docs 正负门禁、shell syntax、策略测试 | 双语链接、失败诊断、release notes/version/tag/source policy | 文档本身等于实现 | ## 先确认 Stage1 状态 -`v0.4.0-abi.6` 发布前应同时满足: +`v0.4.0-abi.7` 发布前应同时满足: - `ISH_EMBED_ABI_VERSION` 为 1; - `ISH_PROTO_VERSION` 为 4; - Swift 源不调用 `ish_embed_session_retain/release`; -- `Package.swift` 仍固定已公开的 `v0.4.0-abi.5`; +- `Package.swift` 仍固定已公开的 `v0.4.0-abi.6`; - 本地构建的新 XCFramework 导出 retain/release、join/soft-halt 等必需符号; - RootFS 没有出现在 Git diff、XCFramework、source archive 或 Release 清单中。 -发布后才把“manifest 固定到 `v0.4.0-abi.6`”加入预期。不能用发布后的预期否定 -发布前仍引用已验证 `v0.4.0-abi.5` 的正确状态。 +发布后才把“manifest 固定到 `v0.4.0-abi.7`”加入预期。不能用发布后的预期否定 +发布前仍引用已验证 `v0.4.0-abi.6` 的正确状态。 ## 快速元数据与脚本门禁 diff --git a/docs/troubleshooting.en.md b/docs/troubleshooting.en.md index 47c24ac..5b8ca4c 100644 --- a/docs/troubleshooting.en.md +++ b/docs/troubleshooting.en.md @@ -29,8 +29,8 @@ checksum, and RootFS provenance, size, and SHA-256. Without this matrix, logs may describe different states. The current correct combination is C ABI 1, wire v4, Swift not calling -retain/release, and a manifest pointing at the public `v0.4.0-abi.5`. After -`v0.4.0-abi.6` publication, only the manifest URL/checksum should switch to the +retain/release, and a manifest pointing at the public `v0.4.0-abi.6`. After +`v0.4.0-abi.7` publication, only the manifest URL/checksum should switch to the maintenance asset. ## Missing retain/release or other link symbols @@ -47,24 +47,24 @@ nm -gU path/to/libIshKernel.a | awk '{print $NF}' | sort -u \ old-ABI-compatible Swift layer rather than expanding this maintenance release. - If a local Stage1 XCFramework lacks them, it was built from an old commit, gitlink, or cache. Rebuild in an isolated path. -- If `v0.4.0-abi.6` is public but the manifest is still `v0.4.0-abi.5`, inspect +- If `v0.4.0-abi.7` is public but the manifest is still `v0.4.0-abi.6`, inspect whether the release commit/default-branch fast-forward completed. Never guess a checksum. ## `Package.swift` looks “not updated” -Between the maintenance PR merge and Release publication, a `v0.4.0-abi.5` +Between the maintenance PR merge and Release publication, a `v0.4.0-abi.6` manifest pin is expected. The release script rebuilds and validates assets from the merged commit, creates a manifest-only release commit, publishes and verifies assets, then fast-forwards the default branch. Thus the branch never advertises a 404 URL. -Only after confirming that the `v0.4.0-abi.6` Release is public is an old +Only after confirming that the `v0.4.0-abi.7` Release is public is an old manifest abnormal: ```sh -gh release view v0.4.0-abi.6 --repo jacklv-coder/ish-arm64-pkg -git ls-remote --tags origin refs/tags/v0.4.0-abi.6 +gh release view v0.4.0-abi.7 --repo jacklv-coder/ish-arm64-pkg +git ls-remote --tags origin refs/tags/v0.4.0-abi.7 git fetch origin git log --oneline --decorate -5 origin/main ``` @@ -110,9 +110,10 @@ cannot confirm cleanup, or a later command cannot recover. and every guest task thread, allowing guest signals to interrupt blocking host syscalls. - The published `v0.4.0-abi.5` adds the finite streaming control-path deadline - on top of that behavior. Planned `v0.4.0-abi.6` completes Swift-marshalling - and stdin-close reuse of the original SPAWN deadline; neither changes guest - signal semantics. + on top of that behavior. Published `v0.4.0-abi.6` completes Swift-marshalling + and stdin-close reuse of the original SPAWN deadline. Planned + `v0.4.0-abi.7` adds guest-atomic no-replace rename; none changes guest signal + semantics. - Source validation should run iSH's `internal-signal-mask` test, package-level host/iOS tests, and a real-RootFS “cancel → native termination confirmed → later command → shutdown” flow. diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index d55da0e..4bc3563 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -26,7 +26,7 @@ git status --short 来源、大小和 SHA-256。没有这组信息,后续日志可能来自不同状态。 当前正确组合是:C ABI 1、wire v4、Swift 不调用 retain/release、manifest 指向已公开 -的 `v0.4.0-abi.5`。发布 `v0.4.0-abi.6` 后,只有 manifest URL/checksum 应切到维护资产。 +的 `v0.4.0-abi.6`。发布 `v0.4.0-abi.7` 后,只有 manifest URL/checksum 应切到维护资产。 ## 链接缺少 retain/release 或其他符号 @@ -44,20 +44,20 @@ nm -gU path/to/libIshKernel.a | awk '{print $NF}' | sort -u \ 旧 ABI 兼容 Swift,而不是扩大本次维护发布范围。 - 如果检查的是本地 Stage1 XCFramework 而符号缺失,说明构建用了旧 commit、旧 gitlink 或缓存;用隔离 build 路径重建。 -- 如果 `v0.4.0-abi.6` 已发布但 manifest 仍是 `v0.4.0-abi.5`,检查 release +- 如果 `v0.4.0-abi.7` 已发布但 manifest 仍是 `v0.4.0-abi.6`,检查 release commit/default branch fast-forward 是否完成;不要手工猜 checksum。 ## Package.swift 看起来“还没更新” -维护 PR 合入到 Release 发布前,manifest 固定 `v0.4.0-abi.5` 是预期状态。发布脚本先 +维护 PR 合入到 Release 发布前,manifest 固定 `v0.4.0-abi.6` 是预期状态。发布脚本先 从合入 commit 重建/验证资产,再创建只更新 manifest 的 release commit,先公开并校验 资产,最后 fast-forward 默认分支。这样默认分支不会引用 404 URL。 -只有已经确认 `v0.4.0-abi.6` Release 公开后,manifest 仍旧才是异常。此时检查: +只有已经确认 `v0.4.0-abi.7` Release 公开后,manifest 仍旧才是异常。此时检查: ```sh -gh release view v0.4.0-abi.6 --repo jacklv-coder/ish-arm64-pkg -git ls-remote --tags origin refs/tags/v0.4.0-abi.6 +gh release view v0.4.0-abi.7 --repo jacklv-coder/ish-arm64-pkg +git ls-remote --tags origin refs/tags/v0.4.0-abi.7 git fetch origin git log --oneline --decorate -5 origin/main ``` @@ -94,8 +94,9 @@ Linux `new_utsname` 65 字节字段宽度的宿主 hostname 触发。 - `v0.4.0-abi.4` 已在嵌入启动线程和每个 guest task 线程明确解除内部 SIGUSR1 屏蔽,使 guest signal 能打断阻塞中的宿主 syscall。 - 已发布的 `v0.4.0-abi.5` 在此基础上增加有限 streaming control-path deadline。 - 待发布的 `v0.4.0-abi.6` 补全 Swift 参数封送和 stdin close 对原始 SPAWN deadline - 的复用;两者都不改变 guest signal 语义。 + 已发布的 `v0.4.0-abi.6` 补全 Swift 参数封送和 stdin close 对原始 SPAWN deadline + 的复用;待发布的 `v0.4.0-abi.7` 新增 guest 原子 no-replace rename;三者都不改变 + guest signal 语义。 - 源码验证应运行 iSH 的 `internal-signal-mask` 测试、包级 host/iOS 测试,并在真实 RootFS 上确认“取消 → native termination confirmed → 后续命令 → shutdown”完整链路。 diff --git a/ffi/ish_ffi.c b/ffi/ish_ffi.c index d168571..102a1ce 100644 --- a/ffi/ish_ffi.c +++ b/ffi/ish_ffi.c @@ -363,7 +363,7 @@ int ish_ffi_install_executable(const char *guest_path, err = generic_setattrat(AT_PWD, temp_path, a, false); if (err < 0) goto fail_temp; - err = generic_renameat(AT_PWD, temp_path, AT_PWD, guest_path); + err = generic_renameat(AT_PWD, temp_path, AT_PWD, guest_path, 0); if (err < 0) goto fail_temp; /* fakefs updates a host file plus SQLite metadata. Reopen and compare the diff --git a/host/ishembed.c b/host/ishembed.c index 7442613..31a5fa7 100644 --- a/host/ishembed.c +++ b/host/ishembed.c @@ -201,6 +201,10 @@ struct ish_embed_instance { /* Private duplicate of the requested supervisor-log sink. A negative fd * means logs are drained and discarded. */ int kernel_log_fd; + /* Immutable guest path of the exact supervisor selected and executed at + * boot. Filesystem helpers spawn this same verified binary rather than a + * RootFS-owned compatibility path. */ + char *supervisor_guest_path; /* hello handshake */ int hello_acked; @@ -1311,6 +1315,8 @@ int ish_embed_boot(const ish_embed_boot_opts_t *opts, size_t off = 0; size_t L = strlen(sup); if (L + 2 > sizeof(argv_packed)) { err = ISH_ERR_INVALID_ARG; goto fail; } + inst->supervisor_guest_path = strdup(sup); + if (!inst->supervisor_guest_path) { err = ISH_ERR_OOM; goto fail; } memcpy(argv_packed, sup, L); argv_packed[L] = 0; off = L + 1; argv_packed[off] = 0; @@ -1436,6 +1442,7 @@ fail:; close_fd_slot(&inst->kernel_log_fd); if (exit_hook_registered) ish_ffi_register_exit_hook(NULL, NULL); destroy_instance_primitives(inst); + free(inst->supervisor_guest_path); free(inst); pthread_mutex_unlock(&g_instance_lock); return saved; @@ -2229,6 +2236,98 @@ int ish_embed_run_oneshot(ish_embed_instance_t *inst, return ISH_OK; } +static int parse_guest_errno_output(const uint8_t *buf, size_t len, + int32_t *out_guest_errno) { + if (!buf || !out_guest_errno || len < 2 || len > 11 || + buf[len - 1] != '\n') + return ISH_ERR_PROTOCOL; + uint32_t value = 0; + for (size_t i = 0; i + 1 < len; i++) { + uint8_t ch = buf[i]; + if (ch < '0' || ch > '9') return ISH_ERR_PROTOCOL; + uint32_t digit = (uint32_t)(ch - '0'); + if (value > (uint32_t)INT32_MAX / 10u || + (value == (uint32_t)INT32_MAX / 10u && + digit > (uint32_t)INT32_MAX % 10u)) + return ISH_ERR_PROTOCOL; + value = value * 10u + digit; + } + *out_guest_errno = (int32_t)value; + return ISH_OK; +} + +int ish_embed_rename_noreplace(ish_embed_instance_t *inst, + const char *source, + const char *destination, + uint32_t timeout_ms, + int32_t *out_guest_errno) { + if (!inst || !source || !destination || !out_guest_errno || + source[0] != '/' || source[1] == '\0' || + destination[0] != '/' || destination[1] == '\0') + return ISH_ERR_INVALID_ARG; + *out_guest_errno = 0; + + uint64_t deadline = timeout_ms > 0 ? now_ms() + timeout_ms : 0; + int gate = instance_call_begin_until(inst, deadline); + if (gate != ISH_OK) return gate; + char *helper_path = inst->supervisor_guest_path + ? strdup(inst->supervisor_guest_path) : NULL; + if (!helper_path) { + instance_call_end(inst); + return ISH_ERR_OOM; + } + + uint32_t remaining_ms = 0; + if (deadline != 0) { + uint64_t now = now_ms(); + if (now >= deadline) { + free(helper_path); + instance_call_end(inst); + return ISH_ERR_TIMEOUT; + } + uint64_t remaining = deadline - now; + remaining_ms = remaining > UINT32_MAX + ? UINT32_MAX : (uint32_t)remaining; + if (remaining_ms == 0) { + free(helper_path); + instance_call_end(inst); + return ISH_ERR_TIMEOUT; + } + } + + const char *argv[] = { + helper_path, + "--rename-noreplace", + source, + destination, + NULL, + }; + ish_embed_spawn_opts_t opts = {0}; + opts.argv = argv; + opts.cwd = "/"; + opts.timeout_ms = remaining_ms; + ish_embed_oneshot_result_t result; + int rc = ish_embed_run_oneshot(inst, &opts, &result); + free(helper_path); + if (rc != ISH_OK) { + instance_call_end(inst); + return rc; + } + + if (result.timed_out) { + rc = ISH_ERR_TIMEOUT; + } else if (result.signal != 0 || result.exit_code != 0) { + rc = ISH_ERR_SUPERVISOR; + } else { + rc = parse_guest_errno_output(result.stdout_buf, result.stdout_len, + out_guest_errno); + } + ish_embed_free(result.stdout_buf); + ish_embed_free(result.stderr_buf); + instance_call_end(inst); + return rc; +} + void ish_embed_free(void *p) { free(p); } int ish_embed_setup_vm_root(ish_embed_instance_t *inst, const char *vm_root) { @@ -2312,6 +2411,7 @@ int ish_embed_shutdown(ish_embed_instance_t *inst, uint32_t grace_ms) { destroy_instance_primitives(inst); g_instance = NULL; + free(inst->supervisor_guest_path); free(inst); pthread_mutex_unlock(&g_instance_lock); return ISH_OK; @@ -2345,6 +2445,7 @@ const char *ish_embed_strerror(int s) { case ISH_ERR_BUSY: return "operation cannot proceed while runtime state is busy"; case ISH_ERR_SUPERVISOR_INSTALL: return "bundled supervisor installation failed"; case ISH_ERR_CONTROL_LIMIT: return "host control queue limit reached"; + case ISH_ERR_UNSUPPORTED: return "feature unavailable in linked native binary"; case ISH_ERR_INTERNAL: return "internal error"; default: return "unknown"; } diff --git a/include/ishembed.h b/include/ishembed.h index 80348ba..bac6e43 100644 --- a/include/ishembed.h +++ b/include/ishembed.h @@ -104,6 +104,7 @@ typedef enum { ISH_ERR_BUSY = -19, /* operation cannot proceed while state busy */ ISH_ERR_SUPERVISOR_INSTALL = -20, /* bundled PID 1 install/verification failed */ ISH_ERR_CONTROL_LIMIT = -21, /* host-to-guest control queue ceiling reached */ + ISH_ERR_UNSUPPORTED = -22, /* feature is unavailable in the linked native binary */ ISH_ERR_INTERNAL = -99, } ish_embed_status_t; @@ -176,6 +177,22 @@ int ish_embed_run_oneshot(ish_embed_instance_t *inst, const ish_embed_spawn_opts_t *opts, ish_embed_oneshot_result_t *out_result); +/* Atomically rename one guest-absolute path without replacing an existing + * destination. The operation runs inside the guest through the exact + * supervisor selected at boot; it does not invoke a shell or compose a + * check-then-rename sequence. + * + * Returns an ISH_* transport/lifecycle status. On ISH_OK, + * *out_guest_errno is 0 for success or a positive Linux errno (for example, + * EEXIST=17 when destination already exists). timeout_ms uses the same + * API-entry deadline semantics as run_oneshot; 0 retains the unbounded legacy + * behavior. source and destination must be non-empty guest-absolute paths. */ +int ish_embed_rename_noreplace(ish_embed_instance_t *inst, + const char *source, + const char *destination, + uint32_t timeout_ms, + int32_t *out_guest_errno); + void ish_embed_free(void *p); /* Spawn and return a session handle for streaming I/O. With timeout_ms == 0, diff --git a/meson.build b/meson.build index f814047..983e28e 100644 --- a/meson.build +++ b/meson.build @@ -259,6 +259,8 @@ test('lifecycle_oneshot_timeout', lifecycle_test, args: ['oneshot-timeout'], timeout: 10) test('lifecycle_oneshot_output', lifecycle_test, args: ['oneshot-output'], timeout: 10) +test('lifecycle_rename_noreplace', lifecycle_test, + args: ['rename-noreplace'], timeout: 10) # The supervisor runs inside Linux/iSH, but its stdin queue has a host-only # deterministic harness. Keep it out of iOS cross builds while running it in diff --git a/scripts/release-version-policy.sh b/scripts/release-version-policy.sh index b581ab1..45ef86a 100644 --- a/scripts/release-version-policy.sh +++ b/scripts/release-version-policy.sh @@ -5,7 +5,7 @@ # existing tag and accidentally creating a stable tag before Stage2 is # integrated and separately authorized. ish_release_stage1_version_allowed() { - [[ "$1" == "v0.4.0-abi.6" ]] + [[ "$1" == "v0.4.0-abi.7" ]] } # Call only after the release entry point has validated strict SemVer. @@ -27,23 +27,25 @@ ish_release_abi_transition_notes() { 这不是稳定 v0.4。本预发布交付 native lifecycle 基础:session retain/release、可等待 kernel thread、soft-halt 与内部精确匹配 wire protocol v4。 -本维护版本从 Swift API 入口建立绝对 deadline,扣除 argv/env/cwd/chroot 封送耗时后 -将剩余时间交给 native;有限 streaming session 会保留原始 SPAWN deadline,并让 stdin -close 在控制队列接纳时复用它。过期会返回 timeout,不会重新开始一段等待;active stdin -write 持有顺序锁时 stdin close 仍会立即返回 busy。 -公开 C ABI 版本仍为 1。Swift 源仍保持 v0.3.3 ABI 兼容且不调用 retain/release; +本维护版本新增无 shell、无 check-then-rename 竞争窗口的 guest 原子重命名能力。 +`ish_embed_rename_noreplace` 通过与当前 runtime 精确匹配的内容寻址 supervisor 执行 +Linux `renameat2(RENAME_NOREPLACE)`;目标已存在时返回 guest `EEXIST`,不会覆盖文件。 +Swift `IshInstance.renameNoReplace` 提供对应类型化错误,并在链接旧 native binary 时 +明确返回 unsupported。 +公开 C ABI 版本仍为 1;这是向后兼容的新增符号。Swift 源仍不调用 retain/release; 完整 Swift lifecycle、类型化状态与 Terminal/VT 改造将在 Stage2 交付。 本 Release 不包含 RootFS,发布脚本也不会上传 RootFS。 This is not stable v0.4. It delivers the native lifecycle foundation: session retain/release, a joinable kernel thread, soft-halt, and internal exact-match -wire protocol v4. This maintenance release establishes an absolute deadline at -Swift API entry, subtracts argv/env/cwd/chroot marshalling, and passes only the -remainder to native. Finite streaming sessions retain the original SPAWN -deadline and reuse it for stdin-close control-queue admission, returning timeout -instead of starting another wait; stdin close still reports busy while an -active write owns its order gate. The public C ABI remains -version 1. Swift source remains v0.3.3-ABI compatible and does not call +wire protocol v4. This maintenance release adds guest-atomic rename without a +shell or a check-then-rename race. `ish_embed_rename_noreplace` invokes Linux +`renameat2(RENAME_NOREPLACE)` through the content-addressed supervisor that +exactly matches the running runtime. An existing destination returns guest +`EEXIST` and is never replaced. Swift `IshInstance.renameNoReplace` exposes a +typed error and reports unsupported when linked to an older native binary. The +public C ABI remains version 1; the new symbol is additive and backward +compatible. Swift source still does not call retain/release; the complete Swift lifecycle, typed statuses, and Terminal/VT changes remain Stage2. This Release does not contain a RootFS, and the release script never uploads one. diff --git a/scripts/release.sh b/scripts/release.sh index baa4b74..fb318ad 100755 --- a/scripts/release.sh +++ b/scripts/release.sh @@ -30,7 +30,7 @@ Environment equivalents: Every release contains libIshKernel.xcframework.zip and its matching IshEmbed-corresponding-source.tar.gz. This script never publishes a RootFS; RootFS provenance, hashing, licensing, and distribution remain a separate flow. -The Stage1 policy accepts only v0.4.0-abi.6 and publishes it as a prerelease. +The Stage1 policy accepts only v0.4.0-abi.7 and publishes it as a prerelease. A suffix-free stable v0.4.0 remains blocked until the Stage2 policy replaces this transition gate. EOF @@ -110,7 +110,7 @@ VERSION_POLICY="$(cd "$(dirname "$0")" && pwd)/release-version-policy.sh" # shellcheck source=release-version-policy.sh source "$VERSION_POLICY" ish_release_stage1_version_allowed "$VERSION" \ - || fail "Stage1 release policy allows only v0.4.0-abi.6; stable v0.4.0 requires Stage2" + || fail "Stage1 release policy allows only v0.4.0-abi.7; stable v0.4.0 requires Stage2" GITHUB_PRERELEASE="$(ish_release_github_prerelease "$VERSION")" if [[ "$VERSION" == *-* ]]; then PRERELEASE="${VERSION#*-}" diff --git a/scripts/test-release-version-policy.sh b/scripts/test-release-version-policy.sh index aae0b4d..7dce3d3 100755 --- a/scripts/test-release-version-policy.sh +++ b/scripts/test-release-version-policy.sh @@ -6,11 +6,11 @@ PKG_ROOT="$(cd "$(dirname "$0")/.." && pwd)" # shellcheck source=release-version-policy.sh source "$PKG_ROOT/scripts/release-version-policy.sh" -ish_release_stage1_version_allowed v0.4.0-abi.6 || { +ish_release_stage1_version_allowed v0.4.0-abi.7 || { printf 'error: authorized Stage1 maintenance tag was rejected\n' >&2 exit 1 } -for forbidden in v0.4.0 v0.4.0-abi.1 v0.4.0-abi.2 v0.4.0-abi.3 v0.4.0-abi.4 v0.4.0-abi.5 v0.4.0-abi.7 v0.4.0-rc.1 v1.2.3; do +for forbidden in v0.4.0 v0.4.0-abi.1 v0.4.0-abi.2 v0.4.0-abi.3 v0.4.0-abi.4 v0.4.0-abi.5 v0.4.0-abi.6 v0.4.0-abi.8 v0.4.0-rc.1 v1.2.3; do if ish_release_stage1_version_allowed "$forbidden"; then printf 'error: Stage1 policy unexpectedly allowed %s\n' "$forbidden" >&2 exit 1 @@ -29,12 +29,12 @@ assert_prerelease_flag() { } } -assert_prerelease_flag v0.4.0-abi.6 true +assert_prerelease_flag v0.4.0-abi.7 true assert_prerelease_flag v1.2.3-rc.1 true assert_prerelease_flag v0.4.0 false assert_prerelease_flag v1.2.3 false -abi_notes="$(ish_release_abi_transition_notes v0.4.0-abi.6)" +abi_notes="$(ish_release_abi_transition_notes v0.4.0-abi.7)" for expected in \ '这不是稳定 v0.4' \ 'This is not stable v0.4' \ @@ -42,12 +42,12 @@ for expected in \ 'joinable kernel thread' \ 'soft-halt' \ 'wire protocol v4' \ - 'Swift API entry' \ - 'argv/env/cwd/chroot marshalling' \ - 'original SPAWN' \ - 'stdin-close control-queue admission' \ - 'stdin close still reports busy' \ - 'Swift source remains' \ + 'renameat2(RENAME_NOREPLACE)' \ + 'content-addressed supervisor' \ + 'existing destination returns guest' \ + 'IshInstance.renameNoReplace' \ + 'older native binary' \ + 'public C ABI remains version 1' \ 'Stage2' \ 'does not contain a RootFS'; do [[ "$abi_notes" == *"$expected"* ]] || { diff --git a/scripts/verify-ios-artifact.sh b/scripts/verify-ios-artifact.sh index 7488970..b4ce01d 100755 --- a/scripts/verify-ios-artifact.sh +++ b/scripts/verify-ios-artifact.sh @@ -323,6 +323,7 @@ for library in "$device_lib" "$simulator_lib"; do _ish_embed_session_retain \ _ish_embed_session_release \ _ish_embed_setup_vm_root \ + _ish_embed_rename_noreplace \ _ish_embed_shutdown \ _ish_embed_bundled_supervisor \ _ish_embed_bundled_supervisor_len \ @@ -354,6 +355,17 @@ device_clang="$(xcrun --sdk iphoneos --find clang)" "$PKG_ROOT/c-tests/smoke.c" "$device_lib" -lsqlite3 \ -o "$VERIFY_TMP/ishembed-device-link" +"$device_clang" \ + -target arm64-apple-ios18.0 \ + -isysroot "$device_sdk" \ + -Wl,-fatal_warnings \ + -I"$PKG_ROOT/include" -I"$PKG_ROOT/protocol" \ + -I"$PKG_ROOT/Sources/CIshEmbed/include" \ + "$PKG_ROOT/c-tests/swift_bridge_smoke.c" \ + "$PKG_ROOT/Sources/CIshEmbed/CIshEmbed.c" \ + "$device_lib" -lsqlite3 \ + -o "$VERIFY_TMP/ishembed-device-bridge-link" + simulator_sdk="$(xcrun --sdk iphonesimulator --show-sdk-path)" simulator_clang="$(xcrun --sdk iphonesimulator --find clang)" "$simulator_clang" \ @@ -364,14 +376,37 @@ simulator_clang="$(xcrun --sdk iphonesimulator --find clang)" "$PKG_ROOT/c-tests/smoke.c" "$simulator_lib" -lsqlite3 \ -o "$VERIFY_TMP/ishembed-simulator-link" +"$simulator_clang" \ + -target arm64-apple-ios18.0-simulator \ + -isysroot "$simulator_sdk" \ + -Wl,-fatal_warnings \ + -I"$PKG_ROOT/include" -I"$PKG_ROOT/protocol" \ + -I"$PKG_ROOT/Sources/CIshEmbed/include" \ + "$PKG_ROOT/c-tests/swift_bridge_smoke.c" \ + "$PKG_ROOT/Sources/CIshEmbed/CIshEmbed.c" \ + "$simulator_lib" -lsqlite3 \ + -o "$VERIFY_TMP/ishembed-simulator-bridge-link" + for linked_binary in \ "$VERIFY_TMP/ishembed-device-link" \ - "$VERIFY_TMP/ishembed-simulator-link"; do + "$VERIFY_TMP/ishembed-simulator-link" \ + "$VERIFY_TMP/ishembed-device-bridge-link" \ + "$VERIFY_TMP/ishembed-simulator-bridge-link"; do linked_exports="$(nm -gU "$linked_binary" | awk '{print $NF}')" ! grep -qx _ish_embed_sha256_matches_hex <<< "$linked_exports" ! grep -qx _ish_embed_supervisor_metadata_valid <<< "$linked_exports" done +for bridge_binary in \ + "$VERIFY_TMP/ishembed-device-bridge-link" \ + "$VERIFY_TMP/ishembed-simulator-bridge-link"; do + bridge_symbols="$(nm -m "$bridge_binary")" + grep -Eq '\(__TEXT,__text\) external _ish_embed_rename_noreplace$' \ + <<< "$bridge_symbols" + ! grep -Eq '\(__TEXT,__text\) weak external _ish_embed_rename_noreplace$' \ + <<< "$bridge_symbols" +done + verify_final_link_build_version() { local binary="$1" local expected_platform="$2" diff --git a/supervisor/ishsv.c b/supervisor/ishsv.c index e7d6732..57103d0 100644 --- a/supervisor/ishsv.c +++ b/supervisor/ishsv.c @@ -51,6 +51,7 @@ #include #include #if defined(__linux__) +#include #include #endif #if defined(__linux__) @@ -136,6 +137,7 @@ extern int (*ishsv_test_waitid_hook)(idtype_t, id_t, siginfo_t *, int); extern pid_t (*ishsv_test_waitpid_hook)(pid_t, int *, int); extern int (*ishsv_test_kill_hook)(pid_t, int); extern pid_t (*ishsv_test_tcgetpgrp_hook)(int); +extern int (*ishsv_test_rename_noreplace_hook)(const char *, const char *); #endif static int supervisor_waitid(idtype_t idtype, id_t id, siginfo_t *info, @@ -251,6 +253,40 @@ static int read_full(int fd, void *buf, size_t len) { return 0; } +/* Private process mode used by the host ABI. It deliberately reports the + * guest errno as a tiny decimal record on stdout so the parent supervisor can + * carry it through the ordinary bounded oneshot path. */ +static int rename_noreplace_guest(const char *source, const char *destination) { +#ifdef ISH_SUPERVISOR_TESTING + if (ishsv_test_rename_noreplace_hook) + return ishsv_test_rename_noreplace_hook(source, destination); +#endif +#if defined(__linux__) && defined(SYS_renameat2) + return (int)syscall(SYS_renameat2, AT_FDCWD, source, + AT_FDCWD, destination, 1u); +#else + (void)source; + (void)destination; + errno = ENOSYS; + return -1; +#endif +} + +static int run_rename_noreplace_helper(int argc, char **argv) { + if (argc != 4 || !argv || strcmp(argv[1], "--rename-noreplace") != 0 || + !argv[2] || !argv[3]) + return 64; + int guest_errno = 0; + if (rename_noreplace_guest(argv[2], argv[3]) < 0) + guest_errno = errno > 0 ? errno : EIO; + char record[32]; + int len = snprintf(record, sizeof(record), "%d\n", guest_errno); + if (len <= 0 || (size_t)len >= sizeof(record) || + write_full(STDOUT_FILENO, record, (size_t)len) < 0) + return 74; + return 0; +} + /* ------------------------ frame send (single-locked) --------------------- * fd 1 is written from one place only (main loop), so no mutex needed. * If we ever multithread the supervisor, add a mutex around emit_frame. */ @@ -1810,7 +1846,9 @@ static int handshake(void) { return emit_frame(ISH_FT_HELLO_ACK, 0, 0, ack, sizeof(ack)); } -int main(void) { +int main(int argc, char **argv) { + if (argc > 1) + return run_rename_noreplace_helper(argc, argv); g_instance_fail_closed = 0; /* PID 1: become a child subreaper just in case (Linux >= 3.4) */ #if defined(__linux__) && defined(PR_SET_CHILD_SUBREAPER) diff --git a/third_party/ish b/third_party/ish index c36dfd2..3d0b4f6 160000 --- a/third_party/ish +++ b/third_party/ish @@ -1 +1 @@ -Subproject commit c36dfd25462737b45559eb48d4b09f799471572e +Subproject commit 3d0b4f6f55108f6d602ac6a2c86df555935b979d