[Bugfix] Reopen CubeShim and VMM logs after rename-based rotation - #1292
Conversation
Review of PR #1292 — Reopen CubeShim and VMM logs after rename-based rotationAI-generated review — not a human approval. OverallThe change is well-structured and the unit tests are thoughtful. The core fix is real and valuable: the old VMM decorator reopened its log file on the first write after a wall-clock hour change and I traced the reopen-control path end to end and it does work in the default configuration: No critical correctness bugs were found in the primary flow. The issues below are documentation/description mismatches, coupling fragility, and minor cleanups. Findings1. PR description does not match the implementation (medium)The PR body claims the VMM "compare[s] the VMM pathname and held descriptor by device/inode at most once per second, so rename-based rotation is detected on a subsequent write without waiting for the next hour." No such device/inode (or pathname) comparison exists anywhere in the diff. Reopen is purely schedule-driven: the hourly 2. Reopen control depends on a fragile log-facade → slog-tag bridge (low)Posted inline at 3. CubeShim reopen-failure behavior contradicts the PR description (low)Posted inline at 4. Duplicate helper functions (low)Posted inline at 5. Minor / informational
Verification notes
|
|
Thank you for your contribution. Please fix your commit messages first. Here is some info: https://github.com/TencentCloud/CubeSandbox/blob/master/CONTRIBUTING.md#commit-messages |
| self.log_reopen_timer | ||
| .read_exact(&mut expirations) | ||
| .map_err(Error::LogReopenTimerRead)?; | ||
| info!(target: LOG_CTRL_REOPEN, "periodic log reopen"); |
There was a problem hiding this comment.
The hourly timerfd reopen is emitted as a log-crate info! record. With the hypervisor's default verbosity (-v 0 → LevelFilter::Warn, see main.rs), info! is filtered out by log::set_max_level before it ever reaches common.rs::Logger, so this control record is a no-op and the timer never triggers a reopen. It also does nothing in --log-stderr (sync) mode, where common.rs drops LOG_CTRL_REOPEN records entirely (line 209) and writes bypass the decorator.
So the "control thread emits the LOG_CTRL_REOPEN control record once per hour" guarantee only holds when the log level is Info or higher AND async file logging is in use. That's fine in practice because the write-triggered identity check (reopen_if_needed_at/file_replaced) covers rename-based rotation within ~1s of the next write, but the docs currently present the timer as an unconditional hourly reopen. Worth either documenting the Info+ / async precondition, or emitting at a level that isn't filtered (or using a path that doesn't depend on the log facade's max level).
| } | ||
| LogType::Rotate => { | ||
| break; | ||
| log_writer.reopen().await?; |
There was a problem hiding this comment.
The PR description says "keep the current descriptor usable when reopen fails; retry on later writes with a one-second cooldown" — that behavior is implemented on the VMM side, but on the CubeShim side a failed reopen() propagates through ? and aborts the whole write_log_rotate invocation. The old descriptor is dropped when the function returns, and the consumer loop restarts the writer after a 3-second sleep with freshly-opened files.
Two consequences worth noting:
log_writer.reopen()andstat_writer.reopen()are not independent: if the log-file reopen succeeds but the stat-file reopen fails (or vice-versa), the error tears down both writers even though one descriptor was already swapped, causing a 3s gap plus a redundant reopen of the healthy file.- During the 3s restart window (or if the file remains unopenable and the 3s retry loop spins), the mpsc channel (cap 1024) can fill and
try_sendinLog::log/Log::statsilently drops messages.
This is recoverable, but it differs from the documented "keep old descriptor, retry with 1s cooldown" behavior. Consider matching the VMM's tolerant handling here (e.g. keep the old descriptor on reopen failure instead of ?), or adjusting the docs to describe the shim's restart behavior.
zhuangel
left a comment
There was a problem hiding this comment.
There already has the timerfd path to reopen logfile, so the log reopen logic in reopen_if_needed checks in RawPlainDecorator and PlainRecordDecorator could be deleted, let PlainRecordDecorator just do log write works.
Could you please help to verify this.
|
@zhuangel Thanks for the suggestion. I verified the timerfd-only simplification against the current logging path. The timerfd remains the only scheduled hourly reopen mechanism, and I removed the duplicate wall-clock check. However, deleting
I changed the control record to This seems to be the smallest safe version of the simplification while preserving the existing rotation behavior. |
|
@ZhengkaiWang Thank you for your thoughts! Here is my view:
Agree with you.
I think the fallback should be removed since no additional CPU overhead should be added for unexpected behavior. Reopen configuration should be kept the same as log rotation. A stable operating environment without external operations. The log of shim is the same. |
| @@ -66,24 +76,17 @@ pub struct PlainRecordDecorator<'a> { | |||
| deco: &'a mut RawPlainDecorator, | |||
| } | |||
|
|
|||
There was a problem hiding this comment.
The PR description claims rename-based rotation is detected "on a subsequent write" via a device/inode comparison ("at most once per second", with "retry on later writes with a one-second cooldown"). This write path performs no such comparison — the old hourly wall-clock check was removed and nothing replaced it. Reopen now happens only when the hourly LOG_CTRL_REOPEN control record reaches this decorator (CubeShim has its own 30-min Rotate event). The docs (service-management.md) describe the schedule-driven behavior correctly, so only the PR body overstates the fast-path detection — but worst-case reopen latency is now bounded by the timer, not by writes.
| F: FnOnce(&mut dyn RecordDecorator) -> io::Result<()>, | ||
| { | ||
| let mut deco = self.deco.borrow_mut(); | ||
| if record.tag() == LOG_CTRL_REOPEN { |
There was a problem hiding this comment.
The hourly reopen depends entirely on this control record surviving the slog_async channel (chan_size(8192), OverflowStrategy::Drop). Under heavy logging the record is silently dropped and the file stays rotated until the next hourly tick. There is also a second loss window in hypervisor/src/common.rs:158-160: records buffered before the logger thread starts are drained with a plain slog_info! (no #LOG_CTRL_REOPEN tag), so a reopen emitted in that window is written as a normal log line and never reopens the file. If a reopen is missed/delayed past the next delaycompress cycle, the writer can be appending to an inode logrotate is about to gzip (writes go to a deleted file) — silent data loss. Worth confirming a missed reopen is acceptable.
| @@ -492,9 +558,15 @@ impl Vmm { | |||
| vcpu_started: Arc<AtomicBool>, | |||
| ) -> Result<Self> { | |||
| let mut epoll = EpollContext::new().map_err(Error::Epoll)?; | |||
There was a problem hiding this comment.
create_log_reopen_timer failure (e.g. EMFILE/ENFILE — the VMM opens many fds) now aborts VMM startup via Error::LogReopenTimerCreate before any VM is created. A logging-rotation convenience turning into a fatal boot failure is a regression; consider making the timer best-effort (log a warning and continue without periodic reopen) so fd exhaustion degrades rotation, not the VM.
| } | ||
| LogType::Rotate => { | ||
| break; | ||
| log_writer.reopen().await?; |
There was a problem hiding this comment.
? propagates a failed reopen() out of write_log_rotate, so the outer retry loop sleeps 3s during which the receiver is not drained; try_send (channel cap 1024) then drops log/stat messages. That drop window pre-exists for write errors, but reopen failures are a new, more likely trigger (e.g. logrotate create 0640 root root producing a file the shim's account can't reopen, which the docs explicitly warn about). Consider logging the reopen failure and continuing the loop instead of aborting it, so one failed reopen doesn't pause the writer.
There was a problem hiding this comment.
Almost done, could you please fix it @ZhengkaiWang.
|
|
||
| drain | ||
| .log( | ||
| &slog::record!(Level::Info, LOG_CTRL_REOPEN, &format_args!(""), b!()), |
There was a problem hiding this comment.
This test does not exercise the reopen path and its assertions will fail. slog::record!(level, msg, args, b) puts the 2nd argument in the record message and leaves the tag as "" — it does not set a tag. PlainDecorator::with_record only takes the reopen branch when record.tag() == LOG_CTRL_REOPEN, so this control record (tag "") is treated as an ordinary record. Meanwhile RawFormat::format_compact writes only record.msg(): the "before"/"after" payloads above are passed as args (never written), and this record writes the literal string LogReopen to the still-open old fd. The rotated file ends up as "LogReopen" and the active file stays empty, so both assert_eq!s at lines 257–258 fail — i.e. cargo test -p logging --lib will fail, contradicting the PR's validation claim.
To exercise the path, the control record must carry the tag and the payloads must be in the msg position, e.g. slog::Record::with_tag(LOG_CTRL_REOPEN, Level::Info, "", &format_args!(""), b!()) (and record!(Level::Info, "before", &format_args!(""), b!()) for the ordinary records).
| // control record visible to the logger facade at | ||
| // that level; the file decorator consumes it | ||
| // without writing a warning line. | ||
| warn!(target: LOG_CTRL_REOPEN, "periodic log reopen"); |
There was a problem hiding this comment.
The whole reopen mechanism now depends on this warn! record surviving two filters that can silently drop it:
-
logfacade max level.log::set_max_level(VmmConfig.log_level)is used at init (hypervisor/src/lib.rs:138). The CLI can't selectError, butVmmConfig.log_level = LevelFilter::Errorvia the library API would drop this record before it reachesPlainDecorator, so the VMM keeps appending to a renamed file thatrotate 24will eventually compress/delete out from under the still-open fd — unbounded hidden disk usage, with no log noise to indicate rotation broke. -
Deferred-logger buffer. With the default config,
defer_logger_thread == true(log_level <= Info, which includes the CLI default Warn), socommon::Logger::log_asyncpushes records emitted beforevcpu_startedintoself.bufferand replays them later viaslog_info!(logger, "{}", v)without the#LOG_CTRL_REOPENtag. If the hourly firing lands before vCPU start, that hour's reopen is skipped and the raw warning text is written to the file as an ordinary line; if vCPUs never start, control records are buffered indefinitely and the file is never reopened.
The warn! level is a deliberate hack to survive the facade filter; it would be more robust to route the reopen out-of-band (the timerfd could feed a channel/flag the control thread checks) or at least guard against log_level < Warn when creating the timer.
| .open(std::path::Path::new(name)) | ||
| } | ||
|
|
||
| fn open_replacement_log_file(name: &str) -> io::Result<std::fs::File> { |
There was a problem hiding this comment.
open_log_file and open_replacement_log_file are byte-for-byte identical (same create(true).append(true).open). The "replacement" semantics are just the same create+append open, so consolidate into a single helper and call it from both PlainDecorator::new and RawPlainDecorator::reopen.
Keep the existing descriptor usable when CubeShim or CubeVMM cannot reopen a rotated path, keep CubeShim diagnostics out of its redirected stderr pipeline, and retain rotation guidance and regression coverage. Assisted-by: Codex: GPT-5 Signed-off-by: zkwang <zkwang@hzinsights.com>
|
|
||
| fn open_replacement_log_file(name: &str) -> io::Result<std::fs::File> { | ||
| std::fs::File::options() | ||
| .create(true) |
There was a problem hiding this comment.
open_replacement_log_file is byte-for-byte identical to open_log_file (both create(true) + append(true) + open). RawPlainDecorator::reopen can just call open_log_file and this second function can be removed — the "replacement" semantics the name suggests don't actually exist (the file is opened by path, not by swapping an existing handle).
| // control record visible to the logger facade at | ||
| // that level; the file decorator consumes it | ||
| // without writing a warning line. | ||
| warn!(target: LOG_CTRL_REOPEN, "periodic log reopen"); |
There was a problem hiding this comment.
This reopen control only works through a fragile chain: the log-crate record (warn!(target: ...)) is bridged to the slog tag by Logger::log_async in hypervisor/src/common.rs (slog_info!(..., #LOG_CTRL_REOPEN, ...)), and only then does PlainDecorator::with_record see record.tag() == LOG_CTRL_REOPEN.
Two consequences worth documenting or hardening:
- In sync mode (
log_stderr = true),Logger::logdrops the control record entirely (common.rs:209), so the hourly timer fires but the decorator never reopens. Today sync mode writes to stderr rather than the file, so this is latent — but any future change that writes to a file in sync mode will silently lose rotation. - If the control record is ever emitted directly through slog without the
#LOG_CTRL_REOPENtag (e.g., the buffered-drain path at common.rs:159 drains without the tag), the reopen is silently skipped.
Consider having the VMM control thread call a dedicated reopen API on the decorator/logger instead of routing the signal through the log-facade record.
| } | ||
| LogType::Rotate => { | ||
| break; | ||
| log_writer.reopen().await?; |
There was a problem hiding this comment.
When reopen() fails here, ? exits write_log_rotate, the outer loop in consumer drops the ReopenableFile (closing the old descriptor), sleeps 3 s, and restarts. That contradicts the PR description's "keep the current descriptor usable when reopen fails; retry on later writes with a one-second cooldown": the shim's descriptor is closed on failure and the retry interval is 3 s (during which messages queue in the bounded 1024-slot channel and are silently dropped under load).
Since ReopenableFile::reopen already keeps self.file on failure, the Rotate arm could log the error and continue the loop, retrying at the next 30-minute Rotate — matching the stated design. If the current propagate-and-restart behavior is intended, the PR description should be updated.
Summary
Keep CubeShim and VMM log files bounded with host-side hourly rotation, retention, and periodic reopen without a service restart.
Root cause
Both writers keep log files open. Host-side
rename + createrotation replaces the pathname while the process still holds the old file descriptor, so each writer needs a safe reopen path that does not discard writes when the replacement is temporarily unavailable.Changes
Rotateevent and reopen both log filestimerfdand emit the existingLOG_CTRL_REOPENcontrol record once per hourlogrotate createown replacement-file creation/etc/logrotate.d/cubesandboxpolicy usingrename + create,delaycompress, compression, and host-owned retentioncopytruncateThe VMM timer is created and consumed by the VMM control thread after its VMM-specific seccomp filter is installed; it is not created from deferred logger initialization or a vCPU/API thread. The pathname/device/inode check remains a write-triggered fast path for arbitrary external rotations. This PR does not impose a fixed byte-size limit such as 100M; host-side rotation and retention remain the deployment policy.
Related to #1290
Related to #1136
Validation
cargo fmt --all -- --checkcargo test -p logging --libcargo check --target x86_64-unknown-linux-gnu -p cube-hypervisor --libcargo check --target x86_64-unknown-linux-gnu -p containerd-shim-cube-rs --tests